You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
982 lines
32 KiB
982 lines
32 KiB
// apps.ts
|
|
// Copyright (C) 2020 Dwayne Harris
|
|
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
import {
|
|
FastifyInstance,
|
|
Plugin,
|
|
DefaultQuery,
|
|
DefaultParams,
|
|
DefaultHeaders,
|
|
DefaultBody,
|
|
RouteShorthandOptions,
|
|
} from 'fastify'
|
|
|
|
import { Server, IncomingMessage, ServerResponse } from 'http'
|
|
import pick from 'lodash/pick'
|
|
|
|
import { appSchema, errorSchema } from '../../schemas'
|
|
import { getUsers, userIdIsValid, userIsValid, updateItem, getUser } from '../../lib/collections'
|
|
import { generateString } from '../../lib/crypto'
|
|
import { containerFor, getItem, normalize, queryItems, createQuerySpec } from '../../lib/database'
|
|
import { unauthorizedError, serverError, badRequestError, notFoundError, forbiddenError } from '../../lib/errors'
|
|
import { attachMedia, deleteMedia } from '../../lib/media'
|
|
import { createInstallationId } from '../../lib/utils'
|
|
|
|
import { APP_PARTITION_KEY, MAX_NAME_LENGTH, INSTALLATION_PARTITION_KEY, ADMINS } from '../../constants'
|
|
import { App, User, Installation, InstallationSettings } from '../../types/collections'
|
|
import { PluginOptions } from '../../types'
|
|
|
|
function availabilityRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
|
|
interface Body {
|
|
name: string
|
|
}
|
|
|
|
const options: RouteShorthandOptions = {
|
|
schema: {
|
|
description: 'Check App ID availability.',
|
|
tags: ['app'],
|
|
body: {
|
|
type: 'object',
|
|
required: ['name'],
|
|
properties: {
|
|
name: {
|
|
type: 'string',
|
|
maxLength: MAX_NAME_LENGTH,
|
|
},
|
|
},
|
|
},
|
|
response: {
|
|
200: {
|
|
description: 'Successful response.',
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
available: { type: 'boolean' },
|
|
},
|
|
},
|
|
400: errorSchema,
|
|
},
|
|
},
|
|
}
|
|
|
|
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/v1/app/available', options, async (request, reply) => {
|
|
if (!server.database) return serverError(reply)
|
|
|
|
const id = normalize(request.body.name)
|
|
|
|
const app = await getItem<App>({
|
|
container: containerFor(server.database.client, 'Apps'),
|
|
partitionKey: APP_PARTITION_KEY,
|
|
id,
|
|
})
|
|
|
|
return {
|
|
id,
|
|
available: !app,
|
|
}
|
|
})
|
|
}
|
|
|
|
function appsRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
|
|
interface Query {
|
|
sort?: string
|
|
continuation?: string
|
|
}
|
|
|
|
const options: RouteShorthandOptions = {
|
|
schema: {
|
|
description: 'Get the list of Apps.',
|
|
tags: ['app'],
|
|
querystring: {
|
|
type: 'object',
|
|
properties: {
|
|
sort: { type: 'string' },
|
|
continuation: { type: 'string' },
|
|
},
|
|
},
|
|
response: {
|
|
200: {
|
|
description: 'Successful response.',
|
|
type: 'object',
|
|
properties: {
|
|
apps: {
|
|
type: 'array',
|
|
items: appSchema,
|
|
},
|
|
continuation: { type: 'string' },
|
|
},
|
|
},
|
|
400: errorSchema,
|
|
},
|
|
},
|
|
}
|
|
|
|
server.get<Query, DefaultParams, DefaultHeaders, DefaultBody>('/v1/apps', options, async (request, reply) => {
|
|
if (!server.database) return serverError(reply)
|
|
|
|
const { sort = 'created', continuation } = request.query
|
|
const query = `SELECT * FROM Apps a WHERE a.pk = '${APP_PARTITION_KEY}' AND a.active = true ORDER BY a.${sort}`
|
|
const container = containerFor(server.database.client, 'Apps')
|
|
|
|
const {
|
|
resources: apps,
|
|
requestCharge,
|
|
continuation: newContinuation,
|
|
} = await container.items.query<App>(query, {
|
|
maxItemCount: 40,
|
|
continuation,
|
|
}).fetchAll()
|
|
|
|
request.log.trace('Query: %d', requestCharge)
|
|
|
|
const users = await getUsers(server.database.client, apps.map(app => app.userId), request.log)
|
|
|
|
return {
|
|
apps: apps.map(app => ({
|
|
...app,
|
|
user: users.find(user => user.id === app.userId),
|
|
userId: undefined,
|
|
})),
|
|
continuation: newContinuation,
|
|
}
|
|
})
|
|
}
|
|
|
|
function selfAppsRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
|
|
interface Query {
|
|
sort?: string
|
|
}
|
|
|
|
const options: RouteShorthandOptions = {
|
|
schema: {
|
|
description: 'Get installed Apps.',
|
|
tags: ['app', 'authentication'],
|
|
querystring: {
|
|
type: 'object',
|
|
properties: {
|
|
sort: { type: 'string' },
|
|
},
|
|
},
|
|
response: {
|
|
200: {
|
|
description: 'Successful response.',
|
|
type: 'object',
|
|
properties: {
|
|
apps: {
|
|
type: 'array',
|
|
items: appSchema,
|
|
},
|
|
},
|
|
},
|
|
400: errorSchema,
|
|
},
|
|
},
|
|
}
|
|
|
|
server.get<Query, DefaultParams, DefaultHeaders, DefaultBody>('/v1/self/apps', options, async (request, reply) => {
|
|
if (!server.database) return serverError(reply)
|
|
if (!request.viewer) return unauthorizedError(reply)
|
|
|
|
const viewer = await getItem<User>({
|
|
container: containerFor(server.database.client, 'Users'),
|
|
id: request.viewer.id,
|
|
})
|
|
|
|
const { sort = 'created' } = request.query
|
|
const query = `SELECT * FROM Apps a WHERE a.pk = '${APP_PARTITION_KEY}' AND a.userId = ${request.viewer.id} ORDER BY a.${sort}`
|
|
const container = containerFor(server.database.client, 'Apps')
|
|
|
|
const { resources: apps, requestCharge } = await container.items.query<App>(query, {}).fetchAll()
|
|
request.log.trace('Query: %d', requestCharge)
|
|
|
|
return {
|
|
apps: apps.map(app => ({
|
|
...app,
|
|
user: viewer,
|
|
userId: undefined,
|
|
})),
|
|
}
|
|
})
|
|
}
|
|
|
|
function createRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
|
|
interface Body {
|
|
version: string
|
|
name: string
|
|
imageUrl?: string
|
|
coverImageUrl?: string
|
|
iconImageUrl?: string
|
|
about?: string
|
|
websiteUrl?: string
|
|
companyName?: string
|
|
composerUrl?: string
|
|
composerSchema?: object
|
|
rendererUrl?: string
|
|
rendererSchema?: object
|
|
initCallbackUrl: string
|
|
composeCallbackUrl: string
|
|
}
|
|
|
|
const options: RouteShorthandOptions = {
|
|
schema: {
|
|
description: 'Create a new App.',
|
|
tags: ['app'],
|
|
body: {
|
|
type: 'object',
|
|
required: ['version', 'name'],
|
|
properties: {
|
|
version: { type: 'string' },
|
|
name: { type: 'string' },
|
|
imageUrl: { type: 'string' },
|
|
coverImageUrl: { type: 'string' },
|
|
iconImageUrl: { 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: {
|
|
description: 'App created.',
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
},
|
|
},
|
|
400: errorSchema,
|
|
}
|
|
},
|
|
}
|
|
|
|
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/v1/app', options, async (request, reply) => {
|
|
if (!server.database) return serverError(reply)
|
|
if (!request.viewer) return unauthorizedError(reply)
|
|
|
|
const valid = await userIdIsValid(server.database.client, request.viewer.id)
|
|
if (!valid) return forbiddenError(reply)
|
|
|
|
const container = containerFor(server.database.client, 'Apps')
|
|
const id = normalize(request.body.name)
|
|
|
|
const {
|
|
version,
|
|
name,
|
|
imageUrl,
|
|
coverImageUrl,
|
|
iconImageUrl,
|
|
about,
|
|
websiteUrl,
|
|
companyName,
|
|
composerUrl,
|
|
composerSchema,
|
|
rendererUrl,
|
|
rendererSchema,
|
|
initCallbackUrl,
|
|
composeCallbackUrl,
|
|
} = request.body
|
|
|
|
const existingApp = await getItem<App>({ container, id })
|
|
if (existingApp) return badRequestError(reply, 'Name already used')
|
|
|
|
await container.items.create<App>({
|
|
id,
|
|
pk: APP_PARTITION_KEY,
|
|
userId: request.viewer.id,
|
|
version,
|
|
name,
|
|
imageUrl,
|
|
coverImageUrl,
|
|
iconImageUrl,
|
|
about,
|
|
websiteUrl,
|
|
companyName,
|
|
composerUrl,
|
|
composerSchema,
|
|
rendererUrl,
|
|
rendererSchema,
|
|
initCallbackUrl,
|
|
composeCallbackUrl,
|
|
rating: 0,
|
|
users: 0,
|
|
publicKey: generateString(20),
|
|
privateKey: generateString(40),
|
|
preinstall: false,
|
|
revisions: [],
|
|
active: false,
|
|
updated: Date.now(),
|
|
created: Date.now(),
|
|
})
|
|
|
|
const mediaContainer = containerFor(server.database.client, 'Media')
|
|
|
|
if (imageUrl) await attachMedia(mediaContainer, imageUrl)
|
|
if (coverImageUrl) await attachMedia(mediaContainer, coverImageUrl)
|
|
if (iconImageUrl) await attachMedia(mediaContainer, iconImageUrl)
|
|
|
|
return { id }
|
|
})
|
|
}
|
|
|
|
function updateRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
|
|
interface Params {
|
|
id: string
|
|
}
|
|
|
|
interface Body {
|
|
version: string
|
|
name: string
|
|
imageUrl?: string
|
|
coverImageUrl?: string
|
|
iconImageUrl?: string
|
|
about?: string
|
|
websiteUrl?: string
|
|
companyName?: string
|
|
composerUrl?: string
|
|
composerSchema?: object
|
|
rendererUrl?: string
|
|
rendererSchema?: object
|
|
initCallbackUrl?: string
|
|
composeCallbackUrl?: string
|
|
}
|
|
|
|
const options: RouteShorthandOptions = {
|
|
schema: {
|
|
description: 'Update an App.',
|
|
tags: ['app'],
|
|
params: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
},
|
|
},
|
|
body: {
|
|
type: 'object',
|
|
required: ['version', 'name'],
|
|
properties: {
|
|
version: { type: 'string' },
|
|
imageUrl: { type: 'string' },
|
|
coverImageUrl: { type: 'string' },
|
|
iconImageUrl: { 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: {
|
|
204: {
|
|
description: 'App updated.',
|
|
type: 'object',
|
|
},
|
|
400: errorSchema,
|
|
}
|
|
},
|
|
}
|
|
|
|
server.put<DefaultQuery, Params, DefaultHeaders, Body>('/v1/app/:id', options, async (request, reply) => {
|
|
if (!server.database) return serverError(reply)
|
|
if (!request.viewer) return unauthorizedError(reply)
|
|
|
|
const valid = await userIdIsValid(server.database.client, request.viewer.id)
|
|
if (!valid) return forbiddenError(reply)
|
|
|
|
const container = containerFor(server.database.client, 'Apps')
|
|
const mediaContainer = containerFor(server.database.client, 'Media')
|
|
|
|
const {
|
|
version,
|
|
name,
|
|
imageUrl,
|
|
coverImageUrl,
|
|
iconImageUrl,
|
|
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<App>()
|
|
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>({
|
|
...app,
|
|
version,
|
|
name: name ?? app.name,
|
|
imageUrl,
|
|
coverImageUrl,
|
|
iconImageUrl,
|
|
about,
|
|
websiteUrl,
|
|
companyName,
|
|
composerUrl,
|
|
composerSchema,
|
|
rendererUrl,
|
|
rendererSchema,
|
|
initCallbackUrl,
|
|
composeCallbackUrl,
|
|
revisions: [
|
|
...app.revisions,
|
|
{
|
|
version: app.version,
|
|
name: app.name,
|
|
imageUrl: app.imageUrl,
|
|
coverImageUrl: app.coverImageUrl,
|
|
iconImageUrl: app.iconImageUrl,
|
|
about: app.about,
|
|
websiteUrl: app.websiteUrl,
|
|
companyName: app.companyName,
|
|
composerUrl: app.composerUrl,
|
|
composerSchema: app.composerSchema,
|
|
rendererUrl: app.rendererUrl,
|
|
rendererSchema: app.rendererSchema,
|
|
initCallbackUrl: app.initCallbackUrl,
|
|
composeCallbackUrl: app.composeCallbackUrl,
|
|
created: app.updated,
|
|
}
|
|
],
|
|
updated: Date.now(),
|
|
})
|
|
|
|
reply.code(204)
|
|
})
|
|
}
|
|
|
|
function getRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
|
|
interface Params {
|
|
id: string
|
|
}
|
|
|
|
const options: RouteShorthandOptions = {
|
|
schema: {
|
|
description: 'Get an App.',
|
|
tags: ['app'],
|
|
params: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
},
|
|
},
|
|
response: {
|
|
200: appSchema,
|
|
400: errorSchema,
|
|
}
|
|
},
|
|
}
|
|
|
|
server.get<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/v1/app/:id', options, async (request, reply) => {
|
|
if (!server.database) return serverError(reply)
|
|
|
|
const app = await getItem<App>({
|
|
container: containerFor(server.database.client, 'Apps'),
|
|
id: request.params.id,
|
|
partitionKey: APP_PARTITION_KEY,
|
|
})
|
|
|
|
if (!app) return notFoundError(reply)
|
|
const userContainer = containerFor(server.database.client, 'Users')
|
|
|
|
let attributes = ['id', 'version', 'name', 'imageUrl', 'coverImageUrl', 'iconImageUrl', 'about', 'websiteUrl', 'companyName', 'rating', 'users', 'updated', 'created']
|
|
|
|
if (request.viewer && request.viewer.id === app.userId) {
|
|
attributes = [...attributes, 'publicKey', 'privateKey', 'revisions', 'composerUrl', 'rendererUrl']
|
|
}
|
|
|
|
return {
|
|
...pick(app, attributes),
|
|
user: await getItem<User>({
|
|
container: userContainer,
|
|
id: app.userId,
|
|
}),
|
|
}
|
|
})
|
|
}
|
|
|
|
function installRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
|
|
interface Params {
|
|
id: string
|
|
}
|
|
|
|
const options: RouteShorthandOptions = {
|
|
schema: {
|
|
description: 'Install an App.',
|
|
tags: ['app'],
|
|
params: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
},
|
|
},
|
|
response: {
|
|
204: {
|
|
description: 'App installed.',
|
|
type: 'object',
|
|
},
|
|
400: errorSchema,
|
|
},
|
|
},
|
|
}
|
|
|
|
server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/v1/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<App>()
|
|
if (!app) return notFoundError(reply)
|
|
|
|
const { resource: viewer } = await viewerItem.read<User>()
|
|
if (!userIsValid(viewer)) return forbiddenError(reply)
|
|
|
|
const installations = await queryItems<Installation>({
|
|
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
|
|
}
|
|
|
|
await appItem.replace<App>({
|
|
...app,
|
|
users: app.users + 1,
|
|
})
|
|
|
|
const installation: Installation = {
|
|
id: createInstallationId(),
|
|
pk: INSTALLATION_PARTITION_KEY,
|
|
userId: request.viewer.id,
|
|
appId: app.id,
|
|
settings: {},
|
|
created: Date.now(),
|
|
}
|
|
|
|
await appContainer.items.create<Installation>(installation)
|
|
|
|
await viewerItem.replace<User>({
|
|
...viewer,
|
|
installations: [
|
|
...viewer.installations,
|
|
installation.id,
|
|
]
|
|
})
|
|
|
|
reply.code(204)
|
|
})
|
|
}
|
|
|
|
function uninstallRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
|
|
interface Params {
|
|
id: string
|
|
}
|
|
|
|
const options: RouteShorthandOptions = {
|
|
schema: {
|
|
description: 'Uninstall App.',
|
|
tags: ['app'],
|
|
params: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
},
|
|
},
|
|
response: {
|
|
204: {
|
|
description: 'App uninstalled.',
|
|
type: 'object',
|
|
},
|
|
400: errorSchema,
|
|
},
|
|
},
|
|
}
|
|
|
|
server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/v1/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<App>()
|
|
if (!app) return notFoundError(reply)
|
|
|
|
const { resource: viewer } = await viewerItem.read<User>()
|
|
if (!userIsValid(viewer)) return forbiddenError(reply)
|
|
|
|
const installations = await queryItems<Installation>({
|
|
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>({
|
|
...app,
|
|
users: app.users - 1,
|
|
})
|
|
|
|
await viewerItem.replace<User>({
|
|
...viewer,
|
|
installations: viewer.installations.filter(i => i !== installation.id),
|
|
})
|
|
|
|
await installationItem.delete()
|
|
|
|
reply.code(204)
|
|
})
|
|
}
|
|
|
|
function installationsRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
|
|
const options: RouteShorthandOptions = {
|
|
schema: {
|
|
description: 'Get authenticated user Installations.',
|
|
tags: ['app', 'authentication'],
|
|
response: {
|
|
200: {
|
|
description: 'Successful response.',
|
|
type: 'object',
|
|
properties: {
|
|
installations: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
app: appSchema,
|
|
settings: {
|
|
type: 'object',
|
|
additionalProperties: true,
|
|
},
|
|
created: { type: 'number' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
400: errorSchema,
|
|
},
|
|
},
|
|
}
|
|
|
|
server.get<DefaultQuery, DefaultParams, DefaultHeaders, DefaultBody>('/v1/installations', options, async (request, reply) => {
|
|
if (!server.database) return serverError(reply)
|
|
if (!request.viewer) return unauthorizedError(reply)
|
|
|
|
const viewer = await getItem<User>({
|
|
container: containerFor(server.database.client, 'Users'),
|
|
id: request.viewer.id,
|
|
})
|
|
|
|
if (!viewer) return unauthorizedError(reply)
|
|
if (!userIsValid(viewer)) return forbiddenError(reply)
|
|
|
|
const container = containerFor(server.database.client, 'Apps')
|
|
|
|
const installations = await queryItems<Installation>({
|
|
container,
|
|
query: createQuerySpec(
|
|
`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<App>({
|
|
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: installations.map(installation => {
|
|
return {
|
|
...installation,
|
|
app: apps.find(app => app.id === installation.appId),
|
|
appId: undefined,
|
|
}
|
|
}),
|
|
}
|
|
})
|
|
}
|
|
|
|
function pendingRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
|
|
interface Query {
|
|
continuation?: string
|
|
}
|
|
|
|
const options: RouteShorthandOptions = {
|
|
schema: {
|
|
description: 'Get Apps awaiting activation.',
|
|
tags: ['app', 'admin'],
|
|
querystring: {
|
|
type: 'object',
|
|
properties: {
|
|
continuation: { type: 'string' },
|
|
},
|
|
},
|
|
response: {
|
|
200: {
|
|
description: 'Successful response.',
|
|
type: 'object',
|
|
properties: {
|
|
apps: {
|
|
type: 'array',
|
|
items: appSchema,
|
|
},
|
|
continuation: { type: 'string' },
|
|
}
|
|
},
|
|
400: errorSchema,
|
|
},
|
|
},
|
|
}
|
|
|
|
server.get<Query, DefaultParams, DefaultHeaders, DefaultBody>('/v1/apps/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 || !ADMINS.includes(viewer.email)) return forbiddenError(reply)
|
|
|
|
const { continuation } = request.query
|
|
const container = containerFor(server.database.client, 'Apps')
|
|
|
|
const { resources: apps, requestCharge, continuation: newContinuation } = await container.items.query<App>(
|
|
createQuerySpec('SELECT * FROM Apps a WHERE a.pk = @pk AND a.active = false', {
|
|
pk: APP_PARTITION_KEY,
|
|
}),
|
|
{
|
|
maxItemCount: 20,
|
|
continuation,
|
|
}
|
|
).fetchAll()
|
|
|
|
request.log.trace('Query: %d', requestCharge)
|
|
|
|
return {
|
|
apps,
|
|
continuation: newContinuation,
|
|
}
|
|
})
|
|
}
|
|
|
|
function activateRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
|
|
interface Params {
|
|
id: string
|
|
}
|
|
|
|
const options: RouteShorthandOptions = {
|
|
schema: {
|
|
description: 'Activate an App.',
|
|
tags: ['app', 'admin'],
|
|
params: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
},
|
|
},
|
|
response: {
|
|
204: {
|
|
description: 'App activated.',
|
|
type: 'object',
|
|
},
|
|
400: errorSchema,
|
|
}
|
|
},
|
|
}
|
|
|
|
server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/v1/app/:id/activate', 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 || !ADMINS.includes(viewer.email)) return forbiddenError(reply)
|
|
|
|
await updateItem<App>(containerFor(server.database.client, 'Apps').item(request.params.id, APP_PARTITION_KEY), { active: true })
|
|
reply.code(204)
|
|
})
|
|
}
|
|
|
|
function setPreinstallRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
|
|
interface Params {
|
|
id: string
|
|
}
|
|
|
|
const options: RouteShorthandOptions = {
|
|
schema: {
|
|
description: 'Update an App to be preinstalled on new User accounts.',
|
|
tags: ['app', 'admin'],
|
|
params: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
},
|
|
},
|
|
response: {
|
|
204: {
|
|
description: 'Successful response.',
|
|
type: 'object',
|
|
},
|
|
400: errorSchema,
|
|
}
|
|
},
|
|
}
|
|
|
|
server.post<DefaultQuery, Params, Headers, DefaultBody>('/v1/app/:id/preinstall', options, async (request, reply) => {
|
|
if (!server.database) return serverError(reply)
|
|
if (!request.viewer) return unauthorizedError(reply)
|
|
|
|
const viewer = await getItem<User>({
|
|
container: containerFor(server.database.client, 'Users'),
|
|
id: request.viewer.id,
|
|
})
|
|
|
|
if (!viewer || !ADMINS.includes(viewer.email)) return forbiddenError(reply)
|
|
|
|
await updateItem<App>(containerFor(server.database.client, 'Apps').item(request.params.id, APP_PARTITION_KEY), {
|
|
preinstall: true,
|
|
active: true,
|
|
})
|
|
|
|
reply.code(204)
|
|
})
|
|
}
|
|
|
|
function updateSettingsRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
|
|
interface Params {
|
|
id: string
|
|
}
|
|
|
|
interface Body {
|
|
settings: InstallationSettings
|
|
}
|
|
|
|
const options: RouteShorthandOptions = {
|
|
schema: {
|
|
description: 'Update Installation settings.',
|
|
tags: ['app'],
|
|
body: {
|
|
type: 'object',
|
|
required: ['settings'],
|
|
properties: {
|
|
settings: { type: 'object' },
|
|
},
|
|
},
|
|
response: {
|
|
204: {
|
|
description: 'Successful response.',
|
|
type: 'object',
|
|
},
|
|
400: errorSchema,
|
|
},
|
|
},
|
|
}
|
|
|
|
server.put<DefaultQuery, Params, DefaultHeaders, Body>('/v1/installation/:id/settings', options, async (request, reply) => {
|
|
if (!server.database) return serverError(reply)
|
|
if (!request.viewer) return unauthorizedError(reply)
|
|
|
|
const container = containerFor(server.database.client, 'Apps')
|
|
const installationItem = container.item(request.params.id, INSTALLATION_PARTITION_KEY)
|
|
const { resource: installation } = await installationItem.read<Installation>()
|
|
|
|
if (!installation) return badRequestError(reply, 'Installation not found')
|
|
if (installation.userId !== request.viewer.id) return badRequestError(reply)
|
|
|
|
await installationItem.replace<Installation>({
|
|
...installation,
|
|
settings: request.body.settings,
|
|
})
|
|
|
|
reply.code(204)
|
|
})
|
|
}
|
|
|
|
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => {
|
|
availabilityRoute(server)
|
|
appsRoute(server)
|
|
selfAppsRoute(server)
|
|
createRoute(server)
|
|
updateRoute(server)
|
|
getRoute(server)
|
|
installRoute(server)
|
|
uninstallRoute(server)
|
|
installationsRoute(server)
|
|
pendingRoute(server)
|
|
activateRoute(server)
|
|
setPreinstallRoute(server)
|
|
updateSettingsRoute(server)
|
|
}
|
|
|
|
export default plugin
|