From 2b8652c13375cc35f63f00b198219d135b7472f2 Mon Sep 17 00:00:00 2001 From: Dwayne Harris Date: Fri, 18 Oct 2019 01:04:55 -0400 Subject: [PATCH] WIP --- src/constants.ts | 4 +- src/lib/media.ts | 36 ++++++++ src/plugins/api/apps.ts | 114 +++++++++++++++++------- src/plugins/api/authentication.ts | 51 ++++++++--- src/plugins/api/groups.ts | 58 ++++++++++-- src/plugins/api/index.ts | 4 +- src/plugins/api/media.ts | 143 ++++++++++++++++++++++++++++++ src/plugins/api/uploads.ts | 51 ----------- src/plugins/api/users.ts | 15 +++- src/schemas.ts | 8 +- src/types/collections.ts | 25 +++++- 11 files changed, 392 insertions(+), 117 deletions(-) create mode 100644 src/lib/media.ts create mode 100644 src/plugins/api/media.ts delete mode 100644 src/plugins/api/uploads.ts diff --git a/src/constants.ts b/src/constants.ts index 06aba65..d0a09f0 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -6,4 +6,6 @@ export const SHORT_TEXT_LENGTH = 100 export const SUBSCRIBER_MAX_SIZE = 100 export const GROUP_LISTING_PARTITION_KEY = 'pk' -export const APP_PARTITION_KEY = 'pk' +export const APP_PARTITION_KEY = 'apk' +export const INSTALLATION_PARTITION_KEY = 'ipk' +export const MEDIA_PARTITION_KEY = 'pk' diff --git a/src/lib/media.ts b/src/lib/media.ts new file mode 100644 index 0000000..d539ab3 --- /dev/null +++ b/src/lib/media.ts @@ -0,0 +1,36 @@ +import { Container } from '@azure/cosmos' +import { BlockBlobURL, SharedKeyCredential, Aborter, ContainerSASPermissions, generateBlobSASQueryParameters, AnonymousCredential } from '@azure/storage-blob' +import moment from 'moment' + +import { MEDIA_PARTITION_KEY } from '../constants' +import { Media } from '../types/collections' + +export function generateSAS(permissions: string, expirationMinutes: number) { + const sharedKeyCredential = new SharedKeyCredential(process.env.BLOB_STORAGE_ACCOUNT!, process.env.BLOB_STORAGE_ACCOUNT_KEY!) + + return generateBlobSASQueryParameters({ + containerName: process.env.BLOB_STORAGE_CONTAINER!, + permissions: ContainerSASPermissions.parse(permissions).toString(), + startTime: new Date(), + expiryTime: moment().add(expirationMinutes, 'm').toDate(), + }, sharedKeyCredential).toString() +} + +export async function deleteMedia(name: string) { + const blockBlobURL = new BlockBlobURL( + `https://${process.env.BLOB_STORAGE_ACCOUNT!}.blob.core.windows.net/${process.env.BLOB_STORAGE_CONTAINER!}/${name}`, + BlockBlobURL.newPipeline(new SharedKeyCredential(process.env.BLOB_STORAGE_ACCOUNT!, process.env.BLOB_STORAGE_ACCOUNT_KEY!)) + ) + + await blockBlobURL.delete(Aborter.none) +} + +export async function attachMedia(container: Container, name: string) { + const mediaItem = container.item(name, MEDIA_PARTITION_KEY) + const { resource: media } = await mediaItem.read() + + await mediaItem.replace({ + ...media, + attached: true, + }) +} diff --git a/src/plugins/api/apps.ts b/src/plugins/api/apps.ts index 16891d9..4f92941 100644 --- a/src/plugins/api/apps.ts +++ b/src/plugins/api/apps.ts @@ -16,11 +16,12 @@ import { getUsers } 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' +import { attachMedia, deleteMedia } from '../../lib/media' import { createInstallationId } from '../../lib/utils' -import { APP_PARTITION_KEY, MAX_NAME_LENGTH } from '../../constants' +import { APP_PARTITION_KEY, MAX_NAME_LENGTH, INSTALLATION_PARTITION_KEY } from '../../constants' -import { App, User } from '../../types/collections' +import { App, User, Installation } from '../../types/collections' import { PluginOptions } from '../../types' function availabilityRoute(server: FastifyInstance) { @@ -322,6 +323,12 @@ function createRoute(server: FastifyInstance() if (!app) return notFoundError(reply) + if (app.imageUrl && !imageUrl) await deleteMedia(app.imageUrl) + if (app.coverImageUrl && !coverImageUrl) await deleteMedia(app.coverImageUrl) + if (app.iconImageUrl && !iconImageUrl) await deleteMedia(app.iconImageUrl) + + if (!app.imageUrl && imageUrl) await attachMedia(mediaContainer, imageUrl) + if (!app.coverImageUrl && coverImageUrl) await attachMedia(mediaContainer, coverImageUrl) + if (!app.iconImageUrl && iconImageUrl) await attachMedia(mediaContainer, iconImageUrl) + await appItem.replace({ ...app, version, @@ -449,7 +465,7 @@ function updateRoute(server: FastifyInstance({ - container: userContainer, - id: request.viewer.id, - }) - - if (viewer && viewer.installations.find(i => i.appId === app.id)) { - installed = true - } - } - if (request.viewer && request.viewer.id === app.userId) { attributes = [...attributes, 'publicKey', 'privateKey', 'revisions', 'composerUrl', 'rendererUrl'] } @@ -509,7 +513,6 @@ function getRoute(server: FastifyInstance() - if (viewer.installations.find(i => i.appId === app.id)) { + const installations = await queryItems({ + container: appContainer, + query: createQuerySpec( + `SELECT a.id FROM Apps a WHERE a.pk = @pk AND a.userId = @userId AND a.appId = @appId`, + { + pk: INSTALLATION_PARTITION_KEY, + userId: request.viewer.id, + appId: app.id, + } + ), + logger: request.log, + }) + + if (installations.length > 0) { reply.code(204) return } @@ -558,16 +574,22 @@ function installRoute(server: FastifyInstance(installation) + await viewerItem.replace({ ...viewer, installations: [ ...viewer.installations, - { - id: createInstallationId(), - appId: app.id, - settings: {}, - created: Date.now(), - }, + installation.id, ] }) @@ -609,11 +631,27 @@ function uninstallRoute(server: FastifyInstance() - if (!viewer.installations.find(i => i.appId === app.id)) { + const installations = await queryItems({ + container: appContainer, + query: createQuerySpec( + `SELECT a.id FROM Apps a WHERE a.pk = @pk AND a.userId = @userId AND a.appId = @appId`, + { + pk: INSTALLATION_PARTITION_KEY, + userId: request.viewer.id, + appId: app.id, + } + ), + logger: request.log, + }) + + if (installations.length === 0) { reply.code(204) return } + const installation = installations[0] + const installationItem = appContainer.item(installation.id, INSTALLATION_PARTITION_KEY) + await appItem.replace({ ...app, users: app.users - 1, @@ -621,9 +659,11 @@ function uninstallRoute(server: FastifyInstance({ ...viewer, - installations: viewer.installations.filter(i => i.appId !== app.id), + installations: viewer.installations.filter(i => i !== installation.id), }) + await installationItem.delete() + reply.code(204) }) } @@ -640,6 +680,7 @@ function installationsRoute(server: FastifyInstance({ - container: containerFor(server.database.client, 'Apps'), + const container = containerFor(server.database.client, 'Apps') + + const installations = await queryItems({ + container, query: createQuerySpec( - `SELECT * FROM Apps a WHERE ARRAY_CONTAINS(@ids, a.id)`, - { - ids: viewer.installations.map(installation => installation.appId), - } + `SELECT * FROM Apps a WHERE a.pk = @pk AND ARRAY_CONTAINS(@ids, a.id)`, + { ids: viewer.installations, pk: INSTALLATION_PARTITION_KEY } + ), + logger: request.log, + }) + + const apps = await queryItems({ + container, + query: createQuerySpec( + `SELECT * FROM Apps a WHERE a.pk = @pk AND ARRAY_CONTAINS(@ids, a.id)`, + { ids: installations.map(i => i.appId), pk: APP_PARTITION_KEY } ), logger: request.log, }) return { - installations: viewer.installations.map(installation => { + installations: installations.map(installation => { return { ...installation, app: apps.find(app => app.id === installation.appId), diff --git a/src/plugins/api/authentication.ts b/src/plugins/api/authentication.ts index 390fb01..6825e8f 100644 --- a/src/plugins/api/authentication.ts +++ b/src/plugins/api/authentication.ts @@ -10,13 +10,14 @@ import { import { Server, IncomingMessage, ServerResponse } from 'http' -import { MIN_ID_LENGTH, MAX_ID_LENGTH, MAX_NAME_LENGTH, MIN_PASSWORD_LENGTH } from '../../constants' +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 { hashPassword, verifyPassword, JWT } from '../../lib/crypto' import { containerFor, getItem, queryItems, normalize, createQuerySpec } from '../../lib/database' import { badRequestError, badRequestFormError, unauthorizedError, serverError } from '../../lib/errors' import { tokenFromHeader } from '../../lib/http' +import { attachMedia } from '../../lib/media' import { createInstallationId } from '../../lib/utils' import { @@ -32,6 +33,7 @@ import { GroupMembershipType, GroupRegistrationType, App, + Installation, } from '../../types/collections' import { PluginOptions } from '../../types' @@ -43,6 +45,9 @@ function registerRoute(server: FastifyInstance('/api/register', options, async (request, reply) => { if (!server.database) return serverError(reply) - const { name, email, password, requiresApproval, privacy, invitation: code } = request.body + const { name, email, password, requiresApproval, privacy, about, imageUrl, coverImageUrl, invitation: code } = request.body const id = normalize(request.body.id) const userContainer = containerFor(server.database.client, 'Users') @@ -137,33 +145,47 @@ function registerRoute(server: FastifyInstance({ - container: containerFor(server.database.client, 'Apps'), + container: appContainer, query: 'SELECT * FROM Apps a WHERE a.active = true AND a.preinstall = true', logger: request.log, }) + const installations: string[] = [] + + for (const app of apps) { + const installation: Installation = { + id: createInstallationId(), + pk: INSTALLATION_PARTITION_KEY, + userId: id, + appId: app.id, + settings: {}, + created: Date.now(), + } + + await appContainer.items.create(installation) + installations.push(installation.id) + } + const user: User = { id, pk: id, t: UserItemType.User, group: groupPartial, name, - about: '', + about, email, emailVerified: false, passwordHash: await hashPassword(password), - installations: apps.map(app => ({ - id: createInstallationId(), - appId: app.id, - settings: {}, - created: Date.now(), - })), + imageUrl, + coverImageUrl, + installations, awards: 0, points: 0, balance: 0, @@ -205,6 +227,11 @@ function registerRoute(server: FastifyInstance({ container: groupContainer, id }) @@ -146,6 +153,9 @@ function createRoute(server: FastifyInstance() + const { + name, + about, + registration, + imageUrl, + coverImageUrl, + iconImageUrl, + } = request.body + + const mediaContainer = containerFor(server.database.client, 'Media') + + if (group.imageUrl && !imageUrl) await deleteMedia(group.imageUrl) + if (group.coverImageUrl && !coverImageUrl) await deleteMedia(group.coverImageUrl) + if (group.iconImageUrl && !iconImageUrl) await deleteMedia(group.iconImageUrl) + + if (!group.imageUrl && imageUrl) await attachMedia(mediaContainer, imageUrl) + if (!group.coverImageUrl && coverImageUrl) await attachMedia(mediaContainer, coverImageUrl) + if (!group.iconImageUrl && iconImageUrl) await attachMedia(mediaContainer, iconImageUrl) + interface Updates { name?: string about?: string registration?: GroupRegistrationType + imageUrl?: string + coverImageUrl?: string + iconImageUrl?: string } let updates: Updates = {} - 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 as GroupRegistrationType + if (name) updates.name = name + if (about) updates.about = about + if (registration) updates.registration = registration as GroupRegistrationType + if (imageUrl) updates.imageUrl = imageUrl + if (coverImageUrl) updates.coverImageUrl = coverImageUrl + if (iconImageUrl) updates.iconImageUrl = iconImageUrl await groupItem.replace({ ...group, diff --git a/src/plugins/api/index.ts b/src/plugins/api/index.ts index 68a7c6b..3ef5899 100644 --- a/src/plugins/api/index.ts +++ b/src/plugins/api/index.ts @@ -9,8 +9,8 @@ import { tokenFromHeader } from '../../lib/http' import apps from './apps' import authentication from './authentication' import groups from './groups' +import media from './media' import posts from './posts' -import uploads from './uploads' import users from './users' import { PluginOptions, HttpError } from '../../types' @@ -83,8 +83,8 @@ const plugin: Plugin = a server.register(apps) server.register(authentication) server.register(groups) + server.register(media) server.register(posts) - server.register(uploads) server.register(users) } diff --git a/src/plugins/api/media.ts b/src/plugins/api/media.ts new file mode 100644 index 0000000..1395a8a --- /dev/null +++ b/src/plugins/api/media.ts @@ -0,0 +1,143 @@ +import { + FastifyInstance, + Plugin, + DefaultQuery, + DefaultParams, + DefaultHeaders, + DefaultBody, + RouteShorthandOptions, +} from 'fastify' + +import { Server, IncomingMessage, ServerResponse } from 'http' + +import { MEDIA_PARTITION_KEY } from '../../constants' +import { errorSchema } from '../../schemas' +import { containerFor, getItem } from '../../lib/database' +import { badRequestError, serverError } from '../../lib/errors' +import { deleteMedia, generateSAS } from '../../lib/media' +import { createId } from '../../lib/utils' + +import { Media } from '../../types/collections' +import { PluginOptions } from '../../types' + +function getSASRoute(server: FastifyInstance) { + const options: RouteShorthandOptions = { + schema: { + response: { + 200: { + type: 'object', + properties: { + sas: { type: 'string' }, + id: { type: 'string' }, + }, + }, + }, + }, + } + + server.get('/api/sas', options, async () => { + return { + sas: generateSAS('arcw', 5), + id: createId(), + } + }) +} + +function addRoute(server: FastifyInstance) { + interface Body { + name: string + size: number + type: string + originalName: string + } + + const options: RouteShorthandOptions = { + schema: { + body: { + type: 'object', + required: ['name', 'size', 'type', 'originalName'], + properties: { + name: { type: 'string' }, + size: { type: 'number' }, + type: { type: 'string' }, + originalName: { type: 'string' }, + }, + }, + response: { + 400: errorSchema, + }, + }, + } + + server.post('/api/media', options, async (request, reply) => { + if (!server.database) return serverError(reply) + + const { name, size, type, originalName } = request.body + + const container = containerFor(server.database.client, 'Media') + const item = await getItem({ + container, + id: name, + partitionKey: MEDIA_PARTITION_KEY, + }) + + reply.code(204) + + if (item) return + + await container.items.create({ + id: name, + pk: MEDIA_PARTITION_KEY, + size, + type, + originalName, + attached: false, + created: Date.now(), + }) + }) +} + +function deleteRoute(server: FastifyInstance) { + interface Body { + name: string + } + + const options: RouteShorthandOptions = { + schema: { + body: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + }, + }, + response: { + 400: errorSchema, + }, + }, + } + + server.post('/api/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) + const { resource: media } = await mediaItem.read() + + if (!media) return badRequestError(reply) + + reply.code(204) + + if (media.attached) return + + await mediaItem.delete() + await deleteMedia(request.body.name) + }) +} + +const plugin: Plugin = async server => { + getSASRoute(server) + addRoute(server) + deleteRoute(server) +} + +export default plugin diff --git a/src/plugins/api/uploads.ts b/src/plugins/api/uploads.ts deleted file mode 100644 index 3179a7b..0000000 --- a/src/plugins/api/uploads.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - FastifyInstance, - Plugin, - DefaultQuery, - DefaultParams, - DefaultHeaders, - DefaultBody, - RouteShorthandOptions, -} from 'fastify' - -import { Server, IncomingMessage, ServerResponse } from 'http' -import moment from 'moment' -import { SharedKeyCredential, ContainerSASPermissions, generateBlobSASQueryParameters } from '@azure/storage-blob' -import { createId } from '../../lib/utils' -import { PluginOptions } from '../../types' - -function getSASRoute(server: FastifyInstance) { - const options: RouteShorthandOptions = { - schema: { - response: { - 200: { - type: 'object', - properties: { - sas: { type: 'string' }, - id: { type: 'string' }, - }, - }, - }, - }, - } - - server.get('/api/sas', options, async () => { - const sharedKeyCredential = new SharedKeyCredential(process.env.BLOB_STORAGE_ACCOUNT!, process.env.BLOB_STORAGE_ACCOUNT_KEY!) - - return { - sas: generateBlobSASQueryParameters({ - containerName: process.env.BLOB_STORAGE_CONTAINER!, - permissions: ContainerSASPermissions.parse('arcw').toString(), - startTime: new Date(), - expiryTime: moment().add(5, 'm').toDate(), - }, sharedKeyCredential).toString(), - id: createId(), - } - }) -} - -const plugin: Plugin = async server => { - getSASRoute(server) -} - -export default plugin diff --git a/src/plugins/api/users.ts b/src/plugins/api/users.ts index ba47207..fba9aed 100644 --- a/src/plugins/api/users.ts +++ b/src/plugins/api/users.ts @@ -13,6 +13,7 @@ import { Server, IncomingMessage, ServerResponse } from 'http' import { unauthorizedError, serverError, notFoundError, badRequestError } from '../../lib/errors' import { getUserBlocks } 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 { userSchema, selfSchema, errorSchema } from '../../schemas' @@ -140,14 +141,20 @@ function updateRoute(server: FastifyInstance(viewer) diff --git a/src/schemas.ts b/src/schemas.ts index a47bd29..9e9cc69 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -35,8 +35,7 @@ export const userSchema: JSONSchema = { properties: { id: { type: 'string' }, name: { type: 'string' }, - imageUrl: { type: 'string' }, - coverImageUrl: { type: 'string' }, + iconImageUrl: { type: 'string' }, }, }, subscription: { type: 'string' }, @@ -63,7 +62,6 @@ export const appSchema: JSONSchema = { users: { type: 'number' }, updated: { type: 'number' }, created: { type: 'number' }, - installed: { type: 'boolean' }, publicKey: { type: 'string' }, privateKey: { type: 'string' }, @@ -92,8 +90,7 @@ export const selfSchema: JSONSchema = { properties: { id: { type: 'string' }, name: { type: 'string' }, - imageUrl: { type: 'string' }, - coverImageUrl: { type: 'string' }, + iconImageUrl: { type: 'string' }, }, }, requiresApproval: { type: 'boolean' }, @@ -122,6 +119,7 @@ export const groupListingSchema: JSONSchema = { about: { type: 'string' }, imageUrl: { type: 'string' }, coverImageUrl: { type: 'string' }, + iconImageUrl: { type: 'string' }, requiresApproval: { type: 'boolean' }, members: { type: 'number' }, posts: { type: 'number' }, diff --git a/src/types/collections.ts b/src/types/collections.ts index 3d87892..c1da985 100644 --- a/src/types/collections.ts +++ b/src/types/collections.ts @@ -12,7 +12,12 @@ // - Partition Key: pk (postId) // Points: total reward value + likes -import { GROUP_LISTING_PARTITION_KEY, APP_PARTITION_KEY } from '../constants' +import { + GROUP_LISTING_PARTITION_KEY, + APP_PARTITION_KEY, + INSTALLATION_PARTITION_KEY, + MEDIA_PARTITION_KEY, +} from '../constants' export enum UserItemType { User = 'user', @@ -102,6 +107,7 @@ export interface Group { codeOfConduct?: string imageUrl?: string coverImageUrl?: string + iconImageUrl?: string registration: GroupRegistrationType status: GroupStatus active: boolean @@ -111,8 +117,7 @@ export interface Group { export interface GroupPartial { id: string name: string - imageUrl?: string - coverImageUrl?: string + iconImageUrl?: string } export interface GroupMembership { @@ -179,7 +184,7 @@ export interface User { email: string emailVerified: boolean passwordHash: string - installations: Installation[] + installations: string[] awards: number // Total Awards points: number balance: number // Currency (Flex) @@ -307,6 +312,8 @@ export interface PostRelationship { export interface Installation { id: string + pk: typeof INSTALLATION_PARTITION_KEY + userId: string appId: string settings: object created: number @@ -358,3 +365,13 @@ export interface App { updated: number created: number } + +export interface Media { + id: string + pk: typeof MEDIA_PARTITION_KEY + size: number + type: string + originalName: string + attached: boolean + created: number +}