[ABANDONED] API server for Flexor social network.
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

// 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