Dwayne Harris 5 years ago
parent
commit
23309ee1b4
  1. 2
      src/constants.ts
  2. 3
      src/lib/crypto.ts
  3. 9
      src/lib/database.ts
  4. 11
      src/lib/utils.ts
  5. 382
      src/plugins/api/apps.ts
  6. 4
      src/plugins/api/authentication.ts
  7. 2
      src/plugins/api/groups.ts
  8. 2
      src/plugins/api/posts.ts
  9. 4
      src/plugins/api/users.ts
  10. 41
      src/schemas.ts
  11. 26
      src/types/collections.ts

2
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'

3
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<string> {
@ -9,6 +10,8 @@ export async function verifyPassword(hash: string, password: string): Promise<bo
return await argon2.verify(hash, password)
}
export const generateString = (length: number) => randomBytes(Math.max(Math.round(length / 2), 5)).toString('hex')
export namespace JWT {
export interface JWTData {
sub?: string

9
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<T>(options: GetItemOptions): Promise<T | undefined
return resource
}
export function normalize(text: string): string {
return text.replace(/[^A-Za-z0-9]/g, '-').toLowerCase()
}
export const normalize = (text: string) => trim(text.replace(/[^A-Za-z0-9]/g, '-').toLowerCase())

11
src/lib/util.ts → 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<void> {
return new Promise(resolve => {

382
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<Server, IncomingMessage, ServerResponse>) {
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<Query, DefaultParams, DefaultHeaders, DefaultBody>('/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<App>(
`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<Server, IncomingMessage, ServerResponse>) {
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<Query, DefaultParams, DefaultHeaders, DefaultBody>('/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<App>(
`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<Server, IncomingMessage, ServerResponse>) {
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<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/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<App>({ 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<App>({
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<Server, IncomingMessage, ServerResponse>) {
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<DefaultQuery, Params, DefaultHeaders, Body>('/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<App>()
if (!app) return notFoundError(reply)
const revision = app.revisions[app.revisions.length - 1]
await appItem.replace<App>({
...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<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => {
appsRoute(server)
selfAppsRoute(server)
createRoute(server)
updateRoute(server)
}
export default plugin

4
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<Server, IncomingMessage, ServerRespon
const options: RouteShorthandOptions = {
schema: {
response: {
200: userSchema,
200: selfSchema,
400: errorSchema,
},
},

2
src/plugins/api/groups.ts

@ -17,7 +17,7 @@ import { errorSchema, groupListingSchema, userSchema } from '../../schemas'
import { unauthorizedError, badRequestError, notFoundError, serverError } from '../../lib/errors'
import { getUsers, getUserMembership } from '../../lib/collections'
import { containerFor, createQuerySpec, queryItems, getItem, normalize } from '../../lib/database'
import { createInvitationCode } from '../../lib/util'
import { createInvitationCode } from '../../lib/utils'
import {
User,

2
src/plugins/api/posts.ts

@ -13,7 +13,7 @@ import { Server, IncomingMessage, ServerResponse } from 'http'
import { SHORT_TEXT_LENGTH, SUBSCRIBER_MAX_SIZE } from '../../constants'
import { userSchema, postSchema, errorSchema } from '../../schemas'
import { unauthorizedError, serverError, badRequestError, badRequestFormError, notFoundError } from '../../lib/errors'
import { trimContent, createPostId } from '../../lib/util'
import { trimContent, createPostId } from '../../lib/utils'
import { getUsers, getApprovedSubscriptions, getUserBlocks } from '../../lib/collections'
import { containerFor, createQuerySpec, queryItems, getItem, normalize } from '../../lib/database'

4
src/plugins/api/users.ts

@ -15,7 +15,7 @@ import { getUserBlocks } from '../../lib/collections'
import { containerFor, createQuerySpec, queryItems, getItem, normalize } from '../../lib/database'
import { MAX_NAME_LENGTH } from '../../constants'
import { userSchema, errorSchema } from '../../schemas'
import { userSchema, selfSchema, errorSchema } from '../../schemas'
import {
User,
@ -103,7 +103,7 @@ function updateRoute(server: FastifyInstance<Server, IncomingMessage, ServerResp
},
},
response: {
200: userSchema,
200: selfSchema,
400: errorSchema,
},
},

41
src/schemas.ts

@ -27,6 +27,7 @@ export const userSchema: JSONSchema = {
properties: {
id: { type: 'string' },
name: { type: 'string' },
about: { type: 'string' },
imageUrl: { type: 'string' },
coverImageUrl: { type: 'string' },
group: {
@ -38,10 +39,32 @@ export const userSchema: JSONSchema = {
coverImageUrl: { type: 'string' },
},
},
subscription: { type: 'string' },
membership: { type: 'string' },
created: { type: 'number' },
},
}
subscription: { type: 'string' },
export const selfSchema: JSONSchema = {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string' },
about: { type: 'string' },
imageUrl: { type: 'string' },
coverImageUrl: { type: 'string' },
group: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
imageUrl: { type: 'string' },
coverImageUrl: { type: 'string' },
},
},
membership: { type: 'string' },
created: { type: 'number' },
},
}
@ -78,6 +101,22 @@ export const groupListingSchema: JSONSchema = {
},
}
export const appSchema: JSONSchema = {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
displayName: { type: 'string' },
imageUrl: { type: 'string' },
coverImageUrl: { type: 'string' },
about: { type: 'string' },
websiteUrl: { type: 'string' },
companyName: { type: 'string' },
version: { type: 'string' },
created: { type: 'number' },
}
}
export const errorSchema: JSONSchema = {
type: 'object',
properties: {

26
src/types/collections.ts

@ -12,7 +12,7 @@
// - Partition Key: pk (postId)
// Points: total reward value + likes
import { GROUP_LISTING_PARTITION_KEY } from '../constants'
import { GROUP_LISTING_PARTITION_KEY, APP_PARTITION_KEY } from '../constants'
export enum UserItemType {
User = 'user',
@ -315,21 +315,22 @@ export interface Installation {
export interface AppRevision {
version: string
displayName: string
imageUrl: string
coverImageUrl: string
description: string
websiteUrl: string
companyName: string
composerUrl: string
composerSchema: object
rendererUrl: string
rendererSchema: object
initCallbackUrl: string
composeCallbackUrl: string
imageUrl?: string
coverImageUrl?: string
about?: string
websiteUrl?: string
companyName?: string
composerUrl?: string
composerSchema?: object
rendererUrl?: string
rendererSchema?: object
initCallbackUrl?: string
composeCallbackUrl?: string
}
export interface App {
id: string
pk: typeof APP_PARTITION_KEY
userId: string
name: string
version: string
@ -338,5 +339,6 @@ export interface App {
currentRevisionIndex: number
publicKey: string
privateKey: string
active: boolean
created: number
}
Loading…
Cancel
Save