diff --git a/src/plugins/api/apps.ts b/src/plugins/api/apps.ts index d702617..3666e6b 100644 --- a/src/plugins/api/apps.ts +++ b/src/plugins/api/apps.ts @@ -14,8 +14,9 @@ import pick from 'lodash/pick' import { appSchema, errorSchema } from '../../schemas' import { getUsers } from '../../lib/collections' import { generateString } from '../../lib/crypto' -import { containerFor, getItem, normalize } from '../../lib/database' +import { containerFor, getItem, normalize, queryItems, createQuerySpec } from '../../lib/database' import { unauthorizedError, serverError, badRequestError, notFoundError } from '../../lib/errors' +import { createInstallationId } from '../../lib/utils' import { APP_PARTITION_KEY, MAX_NAME_LENGTH } from '../../constants' @@ -120,6 +121,7 @@ function appsRoute(server: FastifyInstance({ - container: containerFor(server.database.client, 'Users'), - id: app.userId, - }) + let installed = false + let attributes = ['id', 'version', 'name', 'imageUrl', 'coverImageUrl', 'iconImageUrl', 'about', 'websiteUrl', 'companyName', 'rating', 'users', 'updated', 'created'] + + if (request.viewer) { + const viewer = await getItem({ + 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) { - const attributes = ['id', 'version', 'name', 'imageUrl', 'coverImageUrl', 'iconImageUrl', 'about', 'websiteUrl', 'companyName', 'rating', 'updated', 'created', 'publicKey', 'privateKey', 'revisions'] - return { - ...pick(app, attributes), - user, + attributes = [...attributes, 'publicKey', 'privateKey', 'revisions'] + } + + return { + ...pick(app, attributes), + user: await getItem({ + container: userContainer, + id: app.userId, + }), + installed, + } + }) +} + +function installRoute(server: FastifyInstance) { + interface Params { + id: string + } + + const options: RouteShorthandOptions = { + schema: { + params: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + response: { + 400: errorSchema, } - } else { - const attributes = ['id', 'version', 'name', 'imageUrl', 'coverImageUrl', 'iconImageUrl', 'about', 'websiteUrl', 'companyName', 'rating', 'updated', 'created'] - return { - ...pick(app, attributes), - user, + }, + } + + server.post('/api/app/:id/install', options, async (request, reply) => { + if (!server.database) return serverError(reply) + if (!request.viewer) return unauthorizedError(reply) + + const appContainer = containerFor(server.database.client, 'Apps') + const userContainer = containerFor(server.database.client, 'Users') + + const appItem = appContainer.item(request.params.id, APP_PARTITION_KEY) + const viewerItem = userContainer.item(request.viewer.id, request.viewer.id) + + const { resource: app } = await appItem.read() + if (!app) return notFoundError(reply) + + const { resource: viewer } = await viewerItem.read() + + if (viewer.installations.find(i => i.appId === app.id)) { + reply.code(204) + return + } + + await appItem.replace({ + ...app, + users: app.users + 1, + }) + + await viewerItem.replace({ + ...viewer, + installations: [ + ...viewer.installations, + { + id: createInstallationId(), + appId: app.id, + settings: {}, + created: Date.now(), + }, + ] + }) + + reply.code(204) + }) +} + +function uninstallRoute(server: FastifyInstance) { + interface Params { + id: string + } + + const options: RouteShorthandOptions = { + schema: { + params: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + response: { + 400: errorSchema, } + }, + } + + server.post('/api/app/:id/uninstall', options, async (request, reply) => { + if (!server.database) return serverError(reply) + if (!request.viewer) return unauthorizedError(reply) + + const appContainer = containerFor(server.database.client, 'Apps') + const userContainer = containerFor(server.database.client, 'Users') + + const appItem = appContainer.item(request.params.id, APP_PARTITION_KEY) + const viewerItem = userContainer.item(request.viewer.id, request.viewer.id) + + const { resource: app } = await appItem.read() + if (!app) return notFoundError(reply) + + const { resource: viewer } = await viewerItem.read() + + if (!viewer.installations.find(i => i.appId === app.id)) { + reply.code(204) + return + } + + await appItem.replace({ + ...app, + users: app.users - 1, + }) + + await viewerItem.replace({ + ...viewer, + installations: viewer.installations.filter(i => i.appId !== app.id), + }) + + reply.code(204) + }) +} + +function installationsRoute(server: FastifyInstance) { + const options: RouteShorthandOptions = { + schema: { + response: { + 200: { + type: 'object', + properties: { + installations: { + type: 'array', + items: { + type: 'object', + properties: { + app: appSchema, + settings: { type: 'object' }, + created: { type: 'number' }, + }, + }, + }, + }, + }, + 400: errorSchema, + }, + }, + } + + server.get('/api/installations', options, async (request, reply) => { + if (!server.database) return serverError(reply) + if (!request.viewer) return unauthorizedError(reply) + + const viewer = await getItem({ + container: containerFor(server.database.client, 'Users'), + id: request.viewer.id, + }) + + if (!viewer) return unauthorizedError(reply) + + const apps = await queryItems({ + container: containerFor(server.database.client, 'Apps'), + query: createQuerySpec( + `SELECT * FROM Apps a WHERE ARRAY_CONTAINS(@ids, a.id)`, + { + ids: viewer.installations.map(installation => installation.appId), + } + ), + logger: request.log, + }) + + return { + installations: viewer.installations.map(installation => { + return { + ...installation, + app: apps.find(app => app.id === installation.appId), + appId: undefined, + } + }), } }) } @@ -508,6 +694,9 @@ const plugin: Plugin = a createRoute(server) updateRoute(server) getRoute(server) + installRoute(server) + uninstallRoute(server) + installationsRoute(server) } export default plugin diff --git a/src/plugins/api/authentication.ts b/src/plugins/api/authentication.ts index 16c9bbe..390fb01 100644 --- a/src/plugins/api/authentication.ts +++ b/src/plugins/api/authentication.ts @@ -358,28 +358,7 @@ function selfRoute(server: FastifyInstance({ - container: containerFor(server.database.client, 'Apps'), - query: createQuerySpec( - `SELECT * FROM Apps a WHERE ARRAY_CONTAINS(@ids, a.id)`, - { - ids: viewer.installations.map(installation => installation.appId), - } - ), - logger: request.log, - }) - - return { - ...viewer, - installations: viewer.installations.map(installation => { - return { - ...installation, - app: apps.find(app => app.id === installation.appId), - appId: undefined, - } - }), - } + return viewer }) } diff --git a/src/schemas.ts b/src/schemas.ts index 616ce8f..3eb8ee4 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -59,8 +59,11 @@ export const appSchema: JSONSchema = { websiteUrl: { type: 'string' }, companyName: { type: 'string' }, version: { type: 'string' }, + rating: { type: 'number' }, + users: { type: 'number' }, updated: { type: 'number' }, created: { type: 'number' }, + installed: { type: 'boolean' }, publicKey: { type: 'string' }, privateKey: { type: 'string' }, @@ -91,17 +94,6 @@ export const selfSchema: JSONSchema = { coverImageUrl: { type: 'string' }, }, }, - installations: { - type: 'array', - items: { - type: 'object', - properties: { - app: appSchema, - settings: { type: 'object' }, - created: { type: 'number' }, - }, - }, - }, requiresApproval: { type: 'boolean' }, privacy: { type: 'string' }, membership: { type: 'string' }, diff --git a/src/types/collections.ts b/src/types/collections.ts index 12b785f..3d87892 100644 --- a/src/types/collections.ts +++ b/src/types/collections.ts @@ -349,6 +349,7 @@ export interface App { initCallbackUrl?: string composeCallbackUrl?: string rating: number + users: number publicKey: string privateKey: string preinstall: boolean