From 54a4288ab088a1930a6aca5a09429bfdfb8af299 Mon Sep 17 00:00:00 2001 From: Dwayne Harris Date: Fri, 27 Sep 2019 01:12:26 -0400 Subject: [PATCH] WIP --- src/lib/collections.ts | 33 +++- src/plugins/api/authentication.ts | 47 +++-- src/plugins/api/groups.ts | 273 ++++++++++++++++++++++++++---- src/plugins/api/posts.ts | 48 +----- src/plugins/api/users.ts | 40 +---- src/types/collections.ts | 14 +- 6 files changed, 320 insertions(+), 135 deletions(-) diff --git a/src/lib/collections.ts b/src/lib/collections.ts index daa0d49..4a3838f 100644 --- a/src/lib/collections.ts +++ b/src/lib/collections.ts @@ -3,8 +3,8 @@ import { Logger } from 'fastify' import uniq from 'lodash/uniq' import { DatabaseItem } from '../types' -import { containerFor, createQuerySpec, queryItems } from './database' -import { User, UserSubscription, UserBlock, UserItemType } from '../types/collections' +import { containerFor, createQuerySpec, queryItems, getItem } from './database' +import { User, UserSubscription, UserBlock, GroupMembership, UserItemType, GroupItemType } from '../types/collections' export async function getUsers(client: CosmosClient, ids: string[], logger?: Logger): Promise { return await queryItems({ @@ -51,3 +51,32 @@ export async function getUserBlocks(client: CosmosClient, from: string, to: stri logger, }) } + +export async function getUserMembership(client: CosmosClient, userId: string, logger?: Logger): Promise { + const user = await getItem({ + container: containerFor(client, 'Users'), + id: userId, + }) + + if (!user) return + if (!user.group) return + + const memberships = await queryItems({ + container: containerFor(client, 'Groups'), + query: createQuerySpec( + `SELECT * FROM Groups g WHERE + g.pk = @pk AND + g.t = @type AND + g.userId = @user + `, + { + pk: user.group.id, + type: GroupItemType.Membership, + user: user.id, + } + ), + logger, + }) + + if (memberships.length > 0) return memberships[0] +} diff --git a/src/plugins/api/authentication.ts b/src/plugins/api/authentication.ts index 750b090..bbea2b1 100644 --- a/src/plugins/api/authentication.ts +++ b/src/plugins/api/authentication.ts @@ -80,44 +80,35 @@ function registerRoute(server: FastifyInstance('/api/register', options, async (request, reply) => { if (!server.database) return serverError(reply) - const { name, email, password, invitation } = request.body + const { name, email, password, invitation: code } = request.body const id = normalize(request.body.id) const userContainer = containerFor(server.database.client, 'Users') const groupContainer = containerFor(server.database.client, 'Groups') - const existingUser = await getItem({ - container: userContainer, - id, - logger: request.log - }) - + const existingUser = await getItem({ container: userContainer, id }) if (existingUser) return badRequestFormError(reply, 'id', 'User id already taken') let userPending = false + let invitation: GroupInvitation | undefined let group: Group | undefined let groupPartial: GroupPartial | undefined if (request.body.group) { - group = await getItem({ - container: groupContainer, - id: request.body.group, - logger: request.log - }) - + group = await getItem({ container: groupContainer, id: request.body.group }) if (!group) return badRequestFormError(reply, 'group', 'Group not found') - if (invitation) { + if (code) { const invitationQuery = createQuerySpec(` - SELECT g.id FROM Groups g WHERE - g.id = @invitation + SELECT * FROM Groups g WHERE + g.id = @code g.pk = @group AND g.t = @type AND g.active = true AND g.expiration < GETCURRENTTIMESTAMP() AND g.uses < g.limit `, { - invitation, + code, group: group.id, type: GroupItemType.Invitation, }) @@ -129,6 +120,7 @@ function registerRoute(server: FastifyInstance({ + ...invitation, + uses: invitation.uses + 1, + }) + } } return { @@ -220,11 +221,7 @@ function authenticateRoute(server: FastifyInstance({ - container, - id, - logger: request.log - }) + const user = await getItem({ container, id }) if (!user) return badRequestFormError(reply, 'id', 'User not found') const result = await verifyPassword(user.passwordHash, request.body.password) @@ -300,8 +297,7 @@ function refreshRoute(server: FastifyInstance() - request.log.trace('Get: %d', requestCharge) + const { resource: token } = await tokenItem.read() if (token.expires < Date.now()) return badRequestError(reply, 'Refresh token expired') @@ -338,7 +334,6 @@ function selfRoute(server: FastifyInstance({ container: containerFor(server.database.client, 'Users'), id: request.viewer.id, - logger: request.log }) if (!viewer) return unauthorizedError(reply) diff --git a/src/plugins/api/groups.ts b/src/plugins/api/groups.ts index 334420c..7226004 100644 --- a/src/plugins/api/groups.ts +++ b/src/plugins/api/groups.ts @@ -8,14 +8,16 @@ import { DefaultHeaders, } from 'fastify' -import merge from 'lodash/merge' +import { JSONObject } from '@azure/cosmos' 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 } from '../../lib/collections' +import { getUsers, getUserMembership } from '../../lib/collections' import { containerFor, createQuerySpec, queryItems, getItem, normalize } from '../../lib/database' +import { createInvitationCode } from '../../lib/util' import { User, @@ -29,7 +31,9 @@ import { GroupMembershipType, GroupItemType, BlockType, - UserItemType + UserItemType, + GroupLog, + GroupInvitation, } from '../../types/collections' import { PluginOptions } from '../../types' @@ -72,7 +76,6 @@ function availabilityRoute(server: FastifyInstance({ container: containerFor(server.database.client, 'Groups'), id, - logger: request.log, }) return { @@ -124,8 +127,7 @@ function createRoute(server: FastifyInstance() - request.log.trace('Get: %d', requestCharge) + const { resource: viewer } = await viewerItem.read() const groupContainer = containerFor(server.database.client, 'Groups') @@ -134,11 +136,7 @@ function createRoute(server: FastifyInstance({ - container: groupContainer, - id, - logger: request.log - }) + const existingGroup = await getItem({ container: groupContainer, id }) if (existingGroup) return badRequestError(reply, 'Name already used') const group: Group = { @@ -177,6 +175,14 @@ function createRoute(server: FastifyInstance({ + pk: group.id, + t: GroupItemType.Log, + userId: request.viewer.id, + content: 'created', + created: Date.now(), + }) + return { id: group.id, } @@ -211,13 +217,11 @@ function getRoute(server: FastifyInstance({ container: groupContainer, id: request.params.id, - logger: request.log, }) const combine = async (group: Group, listing: GroupListing) => { @@ -256,6 +260,82 @@ function getRoute(server: FastifyInstance) { + interface Body { + name?: string + about?: string + registration?: string + } + + const options: RouteShorthandOptions = { + schema: { + body: { + type: 'object', + properties: { + name: { + type: 'string', + minLength: MIN_ID_LENGTH, + maxLength: MAX_NAME_LENGTH, + }, + about: { type: 'string' }, + registration: { + type: 'string', + enum: ['open', 'approval', 'closed'], + }, + }, + }, + response: { + 400: errorSchema, + }, + }, + } + + server.put('/api/group/:id', options, async (request, reply) => { + if (!server.database) return serverError(reply) + if (!request.viewer) return unauthorizedError(reply) + + const membership = await getUserMembership(server.database.client, request.viewer.id, request.log) + if (!membership || membership.membership !== GroupMembershipType.Admin) return unauthorizedError(reply) + + const groupContainer = containerFor(server.database.client, 'Groups') + const directoryContainer = containerFor(server.database.client, 'GroupDirectory') + const groupItem = groupContainer.item(request.params.id, request.params.id) + const groupListingItem = directoryContainer.item(request.params.id, GROUP_LISTING_PARTITION_KEY) + + const { resource: group } = await groupItem.read() + if (!group) return notFoundError(reply) + + const { resource: groupListing } = await groupListingItem.read() + + let updates: JSONObject = {} + if (request.body.name) updates.name = request.body.name + if (request.body.about) updates.about = request.body.about + if (request.body.registration) updates.registration = request.body.registration + + await groupItem.replace({ + ...group, + ...updates, + }) + + if (groupListing) { + await groupListingItem.replace({ + ...groupListing, + ...updates, + }) + } + + await groupContainer.items.create({ + pk: group.id, + t: GroupItemType.Log, + userId: request.viewer.id, + content: 'updated', + created: Date.now(), + }) + + reply.code(204) + }) +} + function blockRoute(server: FastifyInstance) { interface Params { id: string @@ -287,11 +367,7 @@ function blockRoute(server: FastifyInstance({ - container: groupContainer, - id: request.params.id, - logger: request.log, - }) + const group = await getItem({ container: groupContainer, id: request.params.id }) if (!group) return notFoundError(reply) await containerFor(server.database.client, 'Users').items.create({ @@ -338,11 +414,7 @@ function unblockRoute(server: FastifyInstance({ - container: groupContainer, - id: request.params.id, - logger: request.log - }) + const group = await getItem({ container: groupContainer, id: request.params.id }) if (!group) return notFoundError(reply) const userBlockQuery = createQuerySpec(`SELECT u.id FROM Users u WHERE u.pk = @pk AND u.blockedId = @blocked AND u.t = @type`, { @@ -406,9 +478,7 @@ function activateRoute(server: FastifyInstance() - request.log.trace('Get: %d', groupRequestCharge) - + const { resource: group } = await groupItem.read() if (!group) return notFoundError(reply) if (group.active && group.status === 'paid') { @@ -423,8 +493,7 @@ function activateRoute(server: FastifyInstance() - request.log.trace('Get: %d', listingRequestCharge) + const { resource: listing } = await listingItem.read() if (!listing) { await directoryContainer.items.create({ @@ -550,11 +619,7 @@ function membersRoute(server: FastifyInstance({ - container: groupContainer, - id: request.params.id, - logger: request.log - }) + const group = await getItem({ container: groupContainer, id: request.params.id }) if (!group) return notFoundError(reply) const { type, continuation } = request.query @@ -590,15 +655,157 @@ function membersRoute(server: FastifyInstance) { + interface Body { + expiration?: number + limit?: number + } + + const options: RouteShorthandOptions = { + schema: { + body: { + type: 'object', + properties: { + expiration: { type: 'number' }, + limit: { type: 'number' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + code: { type: 'string' }, + }, + }, + }, + }, + } + + server.post('/api/group/:id/invitation', options, async (request, reply) => { + if (!server.database) return serverError(reply) + if (!request.viewer) return unauthorizedError(reply) + + const membership = await getUserMembership(server.database.client, request.viewer.id, request.log) + if (!membership || membership.membership !== GroupMembershipType.Admin) return unauthorizedError(reply) + + const container = containerFor(server.database.client, 'Groups') + + const group = await getItem({ container, id: request.params.id }) + if (!group) return notFoundError(reply) + + const code = createInvitationCode() + + await container.items.create({ + id: code, + pk: group.id, + t: GroupItemType.Invitation, + userId: request.viewer.id, + limit: request.body.limit, + expiration: request.body.expiration, + uses: 0, + active: true, + created: Date.now(), + }) + + await container.items.create({ + pk: group.id, + t: GroupItemType.Log, + userId: request.viewer.id, + content: `created invitation: ${code}`, + created: Date.now(), + }) + + return { + code, + } + }) +} + +function logsRoute(server: FastifyInstance) { + interface Query { + continuation?: string + } + + const options: RouteShorthandOptions = { + schema: { + querystring: { + type: 'object', + properties: { + continuation: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + logs: { + type: 'array', + items: { + type: 'object', + properties: { + user: userSchema, + content: { type: 'string' }, + created: { type: 'number' }, + }, + }, + }, + continuation: { type: 'string' }, + }, + }, + 400: errorSchema, + }, + }, + } + + server.get('/api/group/:id/logs', options, async (request, reply) => { + if (!server.database) return serverError(reply) + if (!request.viewer) return unauthorizedError(reply) + + const membership = await getUserMembership(server.database.client, request.viewer.id, request.log) + if (!membership || membership.membership !== GroupMembershipType.Admin) return unauthorizedError(reply) + + const container = containerFor(server.database.client, 'Groups') + + const group = await getItem({ container, id: request.params.id }) + if (!group) return notFoundError(reply) + + const { continuation } = request.query + + const { resources: logs, requestCharge, continuation: newContinuation } = await container.items.query( + `SELECT * FROM Groups g WHERE g.pk = '${group.id}' AND g.t = '${GroupItemType.Log}' ORDER BY g.created DESC`, + { + maxItemCount: 80, + continuation, + } + ).fetchAll() + + request.log.trace('Query: %d', requestCharge) + + const users = await getUsers(server.database.client, logs.map(log => log.userId), request.log) + + return { + logs: logs.map(log => ({ + ...log, + user: users.find(user => user.id === log.userId), + userId: undefined, + })), + continuation: newContinuation, + } + }) +} + const plugin: Plugin = async server => { availabilityRoute(server) createRoute(server) getRoute(server) + updateRoute(server) blockRoute(server) unblockRoute(server) activateRoute(server) listRoute(server) membersRoute(server) + createInvitationRoute(server) + logsRoute(server) } export default plugin diff --git a/src/plugins/api/posts.ts b/src/plugins/api/posts.ts index 053ed9f..2fc1dcf 100644 --- a/src/plugins/api/posts.ts +++ b/src/plugins/api/posts.ts @@ -112,11 +112,7 @@ function doPostRoute(server: FastifyInstance({ - container: userContainer, - id: request.viewer.id, - logger: request.log - }) + const viewer = await getItem({ container: userContainer, id: request.viewer.id }) if (!viewer) return serverError(reply) if (viewer.pending) return badRequestError(reply, 'User requires approval') @@ -125,19 +121,13 @@ function doPostRoute(server: FastifyInstance({ - container: postContainer, - id: request.body.parent, - logger: request.log - }) - + const parent = await getItem({ container: postContainer, id: request.body.parent }) if (!parent) return badRequestFormError(reply, 'parent', 'Invalid parent') const parentRelationship = await getItem({ container: ancestryContainer, id: request.body.parent, partitionKey: parent.root, - logger: request.log }) const parents = parentRelationship ? parentRelationship.parents : [] @@ -238,11 +228,7 @@ function postsByUserRoute(server: FastifyInstance({ - container: userContainer, - id, - logger: request.log - }) + const user = await getItem({ container: userContainer, id }) if (!user) return notFoundError(reply) if (!user.group) return notFoundError(reply) @@ -261,12 +247,7 @@ function postsByUserRoute(server: FastifyInstance({ - container: userContainer, - id: request.viewer.id, - logger: request.log - }) - + const viewer = await getItem({ container: userContainer, id: request.viewer.id }) if (!viewer) return serverError(reply) if (!viewer.group) return unauthorizedError(reply) @@ -278,12 +259,7 @@ function postsByUserRoute(server: FastifyInstance({ - container: userContainer, - id: request.viewer.id, - logger: request.log - }) - + const viewer = await getItem({ container: userContainer, id: request.viewer.id }) if (!viewer) return serverError(reply) if (!viewer.group) return unauthorizedError(reply) @@ -354,12 +330,7 @@ function postRoute(server: FastifyInstance({ - container: postContainer, - id: request.params.id, - logger: request.log - }) - + const post = await getItem({ container: postContainer, id: request.params.id }) if (!post) return notFoundError(reply) const query = createQuerySpec('SELECT * FROM Ancestry a WHERE a.pk = @pk AND ARRAY_CONTAINS(a.parents, @id)', { @@ -400,12 +371,7 @@ function postRoute(server: FastifyInstance({ - container: containerFor(server.database.client, 'Users'), - id: request.viewer.id, - logger: request.log - }) - + const viewer = await getItem({ container: containerFor(server.database.client, 'Users'), id: request.viewer.id }) if (!viewer) return serverError(reply) if (!viewer.group) return unauthorizedError(reply) diff --git a/src/plugins/api/users.ts b/src/plugins/api/users.ts index 20fae27..68c1839 100644 --- a/src/plugins/api/users.ts +++ b/src/plugins/api/users.ts @@ -68,7 +68,6 @@ function availabilityRoute(server: FastifyInstance({ container: containerFor(server.database.client, 'Users'), id, - logger: request.log, }) return { @@ -115,8 +114,7 @@ function updateRoute(server: FastifyInstance() - request.log.trace('Get: %d', requestCharge) + const { resource: viewer } = await viewerItem.read() if (!viewer) return serverError(reply) @@ -172,21 +170,11 @@ function getRoute(server: FastifyInstance({ - container: userContainer, - id: request.params.id, - logger: request.log - }) - + const user = await getItem({ container: userContainer, id: request.params.id }) if (!user) return notFoundError(reply) if (request.viewer) { - const viewer = await getItem({ - container: userContainer, - id: request.viewer.id, - logger: request.log - }) - + const viewer = await getItem({ container: userContainer, id: request.viewer.id }) if (!viewer) return serverError(reply) if (!viewer.group) return unauthorizedError(reply) @@ -224,8 +212,8 @@ function subscribeRoute(server: FastifyInstance({ container: userContainer, id: request.params.id, logger: request.log }) - const viewer = await getItem({ container: userContainer, id: request.viewer.id, logger: request.log }) + const user = await getItem({ container: userContainer, id: request.params.id }) + const viewer = await getItem({ container: userContainer, id: request.viewer.id }) if (!user) return notFoundError(reply) if (!viewer) return serverError(reply) @@ -305,8 +293,8 @@ function unsubscribeRoute(server: FastifyInstance({ container: userContainer, id: request.params.id, logger: request.log }) - const viewer = await getItem({ container: userContainer, id: request.viewer.id, logger: request.log }) + const user = await getItem({ container: userContainer, id: request.params.id }) + const viewer = await getItem({ container: userContainer, id: request.viewer.id }) if (!user) return notFoundError(reply) if (!viewer) return serverError(reply) @@ -360,12 +348,7 @@ function blockRoute(server: FastifyInstance({ - container: userContainer, - id: request.params.id, - logger: request.log - }) - + const user = await getItem({ container: userContainer, id: request.params.id }) if (!user) return notFoundError(reply) if (!user.group) return badRequestError(reply) @@ -413,12 +396,7 @@ function unblockRoute(server: FastifyInstance({ - container: userContainer, - id: request.params.id, - logger: request.log - }) - + const user = await getItem({ container: userContainer, id: request.params.id }) if (!user) return notFoundError(reply) if (!user.group) return badRequestError(reply, 'Invalid operation') diff --git a/src/types/collections.ts b/src/types/collections.ts index 92cad4b..9df2733 100644 --- a/src/types/collections.ts +++ b/src/types/collections.ts @@ -54,6 +54,7 @@ export enum GroupItemType { Report = 'report', Block = 'block', Invitation = 'invitation', + Log = 'log', } export enum GroupMembershipType { @@ -130,8 +131,8 @@ export interface GroupInvitation { pk: string // Group ID t: GroupItemType.Invitation userId: string - limit: number - expiration: number + limit?: number + expiration?: number uses: number active: boolean created: number @@ -157,6 +158,15 @@ export interface GroupBlock { created: number } +export interface GroupLog { + id?: string + pk: string // Group ID + t: GroupItemType.Log + userId: string + content: string + created: number +} + export interface User { id: string pk: string // ID