diff --git a/src/constants.ts b/src/constants.ts index 6d1f291..06aba65 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,4 +4,6 @@ export const MAX_NAME_LENGTH = 80 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 APP_PARTITION_KEY = 'pk' diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index 5464148..1531235 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -1,4 +1,5 @@ import argon2 from 'argon2' +import { randomBytes } from 'crypto' import jwt, { SignOptions, VerifyOptions } from 'jsonwebtoken' export async function hashPassword(password: string): Promise { @@ -9,6 +10,8 @@ export async function verifyPassword(hash: string, password: string): Promise randomBytes(Math.max(Math.round(length / 2), 5)).toString('hex') + export namespace JWT { export interface JWTData { sub?: string diff --git a/src/lib/database.ts b/src/lib/database.ts index 5178d49..9c1968b 100644 --- a/src/lib/database.ts +++ b/src/lib/database.ts @@ -1,10 +1,9 @@ import { CosmosClient, Container, SqlQuerySpec } from '@azure/cosmos' import { Logger } from 'fastify' +import trim from 'lodash/trim' import { QueryParams } from '../types' -export function containerFor(client: CosmosClient, containerId: string): Container { - return client.database('Flexor').container(containerId) -} +export const containerFor = (client: CosmosClient, containerId: string) => client.database('Flexor').container(containerId) export function createQuerySpec(query: string, params: QueryParams = {}): SqlQuerySpec { return { @@ -47,6 +46,4 @@ export async function getItem(options: GetItemOptions): Promise trim(text.replace(/[^A-Za-z0-9]/g, '-').toLowerCase()) diff --git a/src/lib/util.ts b/src/lib/utils.ts similarity index 63% rename from src/lib/util.ts rename to src/lib/utils.ts index 202c372..6b4141f 100644 --- a/src/lib/util.ts +++ b/src/lib/utils.ts @@ -1,4 +1,5 @@ import { v1 } from 'uuid' +import { generateString } from './crypto' export function trimContent(content?: string, length: number = 128): string { if (!content) return '' @@ -7,13 +8,9 @@ export function trimContent(content?: string, length: number = 128): string { return content.slice(0, length).trim() } -export function createPostId(): string { - return 'p' + v1().replace(/-/g, '') -} - -export function createInvitationCode(): string { - return 'i' + v1().replace(/-/g, '') -} +export const createPostId = () => 'p' + v1().replace(/-/g, '') +export const createInvitationCode = () => generateString(8) +export const createInstallationId = () => 'i' + v1().replace(/-/g, '') export function wait(ms: number = 5000): Promise { return new Promise(resolve => { diff --git a/src/plugins/api/apps.ts b/src/plugins/api/apps.ts new file mode 100644 index 0000000..2b0442b --- /dev/null +++ b/src/plugins/api/apps.ts @@ -0,0 +1,382 @@ +import { + FastifyInstance, + Plugin, + DefaultQuery, + DefaultParams, + DefaultHeaders, + DefaultBody, + RouteShorthandOptions, +} from 'fastify' + +import { Server, IncomingMessage, ServerResponse } from 'http' + +import { appSchema, errorSchema } from '../../schemas' +import { generateString } from '../../lib/crypto' +import { containerFor, getItem, normalize } from '../../lib/database' +import { unauthorizedError, serverError, badRequestError, notFoundError } from '../../lib/errors' + +import { APP_PARTITION_KEY } from '../../constants' + +import { + App, + AppRevision, +} from '../../types/collections' + +import { PluginOptions } from '../../types' + +function appsRoute(server: FastifyInstance) { + interface Query { + sort?: string + continuation?: string + } + + const options: RouteShorthandOptions = { + schema: { + querystring: { + type: 'object', + properties: { + sort: { type: 'string' }, + continuation: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + apps: { + type: 'array', + items: appSchema, + }, + continuation: { type: 'string' }, + }, + }, + 400: errorSchema, + }, + }, + } + + server.get('/api/apps', options, async (request, reply) => { + if (!server.database) return serverError(reply) + + const { sort = 'created', continuation } = request.query + const container = containerFor(server.database.client, 'Apps') + + const { resources: apps, requestCharge, continuation: newContinuation } = await container.items.query( + `SELECT + a.id, + a.name, + a.rating, + a.created, + r.displayName, + r.imageUrl, + r.coverImageUrl, + r.about, + r.websiteUrl, + r.companyName + FROM Apps a JOIN r IN a.revisions[a.currentRevisionIndex] + WHERE a.pk = '${APP_PARTITION_KEY}' AND a.active = true ORDER BY a.${sort}`, + { + maxItemCount: 40, + continuation, + } + ).fetchAll() + + request.log.trace('Query: %d', requestCharge) + + return { + apps, + continuation: newContinuation, + } + }) +} + +function selfAppsRoute(server: FastifyInstance) { + interface Query { + sort?: string + } + + const options: RouteShorthandOptions = { + schema: { + querystring: { + type: 'object', + properties: { + sort: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + apps: { + type: 'array', + items: appSchema, + }, + }, + }, + 400: errorSchema, + }, + }, + } + + server.get('/api/self/apps', options, async (request, reply) => { + if (!server.database) return serverError(reply) + if (!request.viewer) return unauthorizedError(reply) + + const { sort = 'created' } = request.query + const container = containerFor(server.database.client, 'Apps') + + const { resources: apps, requestCharge, continuation: newContinuation } = await container.items.query( + `SELECT + a.id, + a.name, + a.rating, + a.created, + r.displayName, + r.imageUrl, + r.coverImageUrl, + r.about, + r.websiteUrl, + r.companyName + FROM Apps a JOIN r IN a.revisions[a.currentRevisionIndex] + WHERE a.pk = '${APP_PARTITION_KEY}' AND a.userId = ${request.viewer.id} ORDER BY a.${sort}`, + {} + ).fetchAll() + + request.log.trace('Query: %d', requestCharge) + + return { + apps, + } + }) +} + +function createRoute(server: FastifyInstance) { + interface Body { + name: string + displayName: string + version: string + imageUrl?: string + coverImageUrl?: string + about?: string + websiteUrl?: string + companyName?: string + composerUrl?: string + composerSchema?: object + rendererUrl?: string + rendererSchema?: object + initCallbackUrl: string + composeCallbackUrl: string + } + + const options: RouteShorthandOptions = { + schema: { + body: { + type: 'object', + required: ['name', 'displayName', 'version'], + properties: { + name: { type: 'string' }, + displayName: { type: 'string' }, + version: { type: 'string' }, + imageUrl: { type: 'string' }, + coverImageUrl: { type: 'string' }, + about: { type: 'string' }, + websiteUrl: { type: 'string' }, + companyName: { type: 'string' }, + composerUrl: { type: 'string' }, + composerSchema: { type: 'object' }, + rendererUrl: { type: 'string' }, + rendererSchema: { type: 'object' }, + initCallbackUrl: { type: 'string' }, + composeCallbackUrl: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + 400: errorSchema, + } + }, + } + + server.post('/api/app', options, async (request, reply) => { + if (!server.database) return serverError(reply) + if (!request.viewer) return unauthorizedError(reply) + + const container = containerFor(server.database.client, 'Apps') + const id = normalize(request.body.name) + + const { + name, + displayName, + version, + imageUrl, + coverImageUrl, + about, + websiteUrl, + companyName, + composerUrl, + composerSchema, + rendererUrl, + rendererSchema, + initCallbackUrl, + composeCallbackUrl, + } = request.body + + const existingApp = await getItem({ container, id }) + if (existingApp) return badRequestError(reply, 'Name already used') + + const revision: AppRevision = { + version, + displayName, + imageUrl, + coverImageUrl, + about, + websiteUrl, + companyName, + composerUrl, + composerSchema, + rendererUrl, + rendererSchema, + initCallbackUrl, + composeCallbackUrl, + } + + await container.items.create({ + id, + pk: APP_PARTITION_KEY, + userId: request.viewer.id, + name, + version, + rating: 0, + revisions: [revision], + currentRevisionIndex: 0, + publicKey: generateString(20), + privateKey: generateString(40), + active: false, + created: Date.now(), + }) + + return { + id, + } + }) +} + +function updateRoute(server: FastifyInstance) { + interface Params { + id: string + } + + interface Body { + version: string + displayName?: string + imageUrl?: string + coverImageUrl?: string + about?: string + websiteUrl?: string + companyName?: string + composerUrl?: string + composerSchema?: object + rendererUrl?: string + rendererSchema?: object + initCallbackUrl: string + composeCallbackUrl: string + } + + const options: RouteShorthandOptions = { + schema: { + body: { + type: 'object', + required: ['version'], + properties: { + version: { type: 'string' }, + displayName: { type: 'string' }, + imageUrl: { type: 'string' }, + coverImageUrl: { type: 'string' }, + about: { type: 'string' }, + websiteUrl: { type: 'string' }, + companyName: { type: 'string' }, + composerUrl: { type: 'string' }, + composerSchema: { type: 'object' }, + rendererUrl: { type: 'string' }, + rendererSchema: { type: 'object' }, + initCallbackUrl: { type: 'string' }, + composeCallbackUrl: { type: 'string' }, + }, + }, + response: { + 400: errorSchema, + } + }, + } + + server.put('/api/app/:id', options, async (request, reply) => { + if (!server.database) return serverError(reply) + if (!request.viewer) return unauthorizedError(reply) + + const container = containerFor(server.database.client, 'Apps') + const id = request.params.id + + const { + version, + displayName, + imageUrl, + coverImageUrl, + about, + websiteUrl, + companyName, + composerUrl, + composerSchema, + rendererUrl, + rendererSchema, + initCallbackUrl, + composeCallbackUrl, + } = request.body + + const appItem = container.item(request.params.id, APP_PARTITION_KEY) + const { resource: app } = await appItem.read() + if (!app) return notFoundError(reply) + + const revision = app.revisions[app.revisions.length - 1] + + await appItem.replace({ + ...app, + revisions: [ + ...app.revisions, + { + ...revision, + version, + displayName: displayName || revision.displayName, + imageUrl, + coverImageUrl, + about, + websiteUrl, + companyName, + composerUrl, + composerSchema, + rendererUrl, + rendererSchema, + initCallbackUrl, + composeCallbackUrl, + } + ], + currentRevisionIndex: app.currentRevisionIndex + 1, + version, + }) + + reply.code(201) + }) +} + +const plugin: Plugin = async server => { + appsRoute(server) + selfAppsRoute(server) + createRoute(server) + updateRoute(server) +} + +export default plugin diff --git a/src/plugins/api/authentication.ts b/src/plugins/api/authentication.ts index 0e9ff67..d28daf1 100644 --- a/src/plugins/api/authentication.ts +++ b/src/plugins/api/authentication.ts @@ -11,7 +11,7 @@ import { import { Server, IncomingMessage, ServerResponse } from 'http' import { MIN_ID_LENGTH, MAX_ID_LENGTH, MAX_NAME_LENGTH, MIN_PASSWORD_LENGTH } from '../../constants' -import { tokenResponseSchema, userSchema, errorSchema } from '../../schemas' +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' @@ -322,7 +322,7 @@ function selfRoute(server: FastifyInstance