From f47d1301e6731198376e2c9bd8ce433499997c28 Mon Sep 17 00:00:00 2001 From: Lautaro Dragan Date: Tue, 28 May 2019 20:12:09 -0300 Subject: [PATCH] feat: archive uploads (#957) --- src/api/API.ts | 12 +++-- src/api/Path.ts | 1 + src/api/archives/PostArchive.ts | 18 +++++++ src/api/routes.ts | 21 ++++++-- src/app.ts | 16 ++++++- src/configuration.ts | 4 ++ src/controllers/ArchiveController.ts | 71 ++++++++++++++++++++++++++++ src/daos/PoetNodeDao.ts | 27 +++++++++++ src/errors/errors.ts | 20 +++++++- src/helpers/ethereum.ts | 5 ++ src/middlewares/authorization.ts | 2 +- 11 files changed, 184 insertions(+), 13 deletions(-) create mode 100644 src/api/archives/PostArchive.ts create mode 100644 src/controllers/ArchiveController.ts create mode 100644 src/daos/PoetNodeDao.ts diff --git a/src/api/API.ts b/src/api/API.ts index 62e21242e..f74799715 100644 --- a/src/api/API.ts +++ b/src/api/API.ts @@ -9,6 +9,7 @@ import { createModuleLogger, LoggingConfiguration } from '../utils/Logging/Loggi import { SendEmailConfiguration } from '../utils/SendEmail' import { AccountController } from '../controllers/AccountController' +import { ArchiveController } from '../controllers/ArchiveController' import { errorHandling } from '../middlewares/errorHandling' import { logger } from '../middlewares/logger' @@ -40,7 +41,7 @@ interface APIMethods { stop(): Promise } -const init = (accountController: AccountController) => ({ +const init = (accountController: AccountController, archiveController: ArchiveController) => ({ maxApiRequestLimitForm, maxApiRequestLimitJson, passwordComplex, @@ -51,7 +52,7 @@ const init = (accountController: AccountController) => ({ loggingConfiguration, }: APIConfiguration) => { const app = new Koa() - const route = routes(accountController)( + const route = routes(accountController, archiveController)( passwordComplex, sendEmail, poetUrl, @@ -93,13 +94,16 @@ const stopAPI = async (server: any, logger: Pino.Logger) => { logger.info('Stopped API.') } -export const API = (accountController: AccountController) => (configuration: APIConfiguration): APIMethods => { +export const API = ( + accountController: AccountController, + archiveController: ArchiveController, +) => (configuration: APIConfiguration): APIMethods => { const { loggingConfiguration } = configuration const logger = createModuleLogger(loggingConfiguration)(__dirname) return { async start(): Promise { - const app = init(accountController)(configuration) + const app = init(accountController, archiveController)(configuration) this.server = await startAPI(app, configuration, logger) return this }, diff --git a/src/api/Path.ts b/src/api/Path.ts index eae18dda2..6670dc8b6 100644 --- a/src/api/Path.ts +++ b/src/api/Path.ts @@ -13,4 +13,5 @@ export enum Path { TOKENS = '/tokens', TOKENS_TOKENID = '/tokens/:tokenId', HEALTH = '/health', + ARCHIVES = '/archives', } diff --git a/src/api/archives/PostArchive.ts b/src/api/archives/PostArchive.ts new file mode 100644 index 000000000..64f56d0bd --- /dev/null +++ b/src/api/archives/PostArchive.ts @@ -0,0 +1,18 @@ +import { ArchiveController } from '../../controllers/ArchiveController' +import { IncorrectToken } from '../../errors/errors' +import { Token } from '../Tokens' + +export const PostArchive = (archiveController: ArchiveController) => async (ctx: any, next: any): Promise => { + const { req } = ctx.request + const { user, tokenData } = ctx.state + + const allowedTokens = [Token.ApiKey.meta.name, Token.TestApiKey.meta.name] + + if (!allowedTokens.includes(tokenData.data.meta.name)) + throw new IncorrectToken(tokenData.data.meta.name, allowedTokens) + + const response = await archiveController.postArchive(user, req, tokenData.data.meta.network) + + ctx.status = 200 + ctx.body = response +} diff --git a/src/api/routes.ts b/src/api/routes.ts index 47c28b81c..fdbda18da 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -1,7 +1,9 @@ import * as KoaRouter from 'koa-router' import { AccountController } from '../controllers/AccountController' -import { authorization } from '../middlewares/authorization' +import { ArchiveController } from '../controllers/ArchiveController' + +import { Authorization } from '../middlewares/authorization' import { isLoggedIn } from '../middlewares/isLoggedIn' import { monitor } from '../middlewares/monitor' import { requireEmailVerified } from '../middlewares/requireEmailVerified' @@ -36,7 +38,9 @@ import { CreateWork, CreateWorkSchema } from './works/CreateWork' import { GetWork, GetWorkSchema } from './works/GetWork' import { GetWorks } from './works/GetWorks' -export const routes = (accountController: AccountController) => ( +import { PostArchive } from './archives/PostArchive' + +export const routes = (accountController: AccountController, archiveController: ArchiveController) => ( passwordComplexConfiguration: PasswordComplexConfiguration, sendEmailConfiguration: SendEmailConfiguration, poetUrl: string, @@ -44,6 +48,7 @@ export const routes = (accountController: AccountController) => ( testPoetUrl: string, ) => { const router = new KoaRouter() + const authorization = Authorization(accountController) router.use([Path.WORKS, Path.WORKS_WORKID], (ctx: any, next: any) => { ctx.set('Access-Control-Allow-Methods', 'POST,GET') @@ -67,7 +72,7 @@ export const routes = (accountController: AccountController) => ( Path.TOKENS, Path.TOKENS_TOKENID, ], - authorization(accountController), + authorization, ) router.use([Path.WORKS, Path.WORKS_WORKID], requireEmailVerified()) @@ -94,13 +99,13 @@ export const routes = (accountController: AccountController) => ( ) router.patch( Path.ACCOUNTS_ID, - authorization(accountController), + authorization, validate(PatchAccountSchema), PatchAccount(accountController), ) router.post( Path.ACCOUNTS_ID_POE_CHALLENGE, - authorization(accountController), + authorization, validate(PostAccountPoeChallengeSchema), PostAccountPoeChallenge(accountController), ) @@ -137,5 +142,11 @@ export const routes = (accountController: AccountController) => ( router.get(Path.WORKS_WORKID, validate({ params: GetWorkSchema }), GetWork(poetUrl, testPoetUrl)) router.get(Path.WORKS, GetWorks(poetUrl, testPoetUrl)) + router.post( + Path.ARCHIVES, + authorization, + PostArchive(archiveController), + ) + return router } diff --git a/src/app.ts b/src/app.ts index aa1aada49..a29c9fd0a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,7 +4,9 @@ import * as Pino from 'pino' import { API } from './api/API' import { Configuration } from './configuration' import { AccountController } from './controllers/AccountController' +import { ArchiveController } from './controllers/ArchiveController' import { AccountDao } from './daos/AccountDao' +import { PoetNode } from './daos/PoetNodeDao' import { initVault } from './initVault' import { loadConfigurationWithDefaults } from './loadConfiguration' import { loggingConfigurationToPinoConfiguration } from './utils/Logging/Logging' @@ -59,7 +61,19 @@ export async function app(localVars: any = {}) { }, }) - const frostAPI = await API(accountController)(configurationFrostAPI).start() + const archiveController = ArchiveController({ + dependencies: { + logger: logger.child({ file: 'ArchiveController' }), + mainnetNode: PoetNode(configuration.poetUrl), + testnetNode: PoetNode(configuration.testPoetUrl), + }, + configuration: { + poeContractAddress: configuration.poeContractAddress, + poeBalanceMinimum: configuration.poeBalanceMinimum, + }, + }) + + const frostAPI = await API(accountController, archiveController)(configurationFrostAPI).start() await accountDao.createIndices() diff --git a/src/configuration.ts b/src/configuration.ts index 3ad6d61b7..44a9d0ea0 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -60,6 +60,8 @@ export interface Configuration readonly transactionalMandrill: string readonly jwt: string readonly skipVault: boolean + readonly poeContractAddress: string + readonly poeBalanceMinimum: number } export const configuration: Configuration = { @@ -107,4 +109,6 @@ export const configuration: Configuration = { maildevIgnoreTLS: true, loggingLevel: 'info', loggingPretty: true, + poeContractAddress: '0x0e0989b1f9b8a38983c2ba8053269ca62ec9b195', + poeBalanceMinimum: 1000, } diff --git a/src/controllers/ArchiveController.ts b/src/controllers/ArchiveController.ts new file mode 100644 index 000000000..2f87d0bac --- /dev/null +++ b/src/controllers/ArchiveController.ts @@ -0,0 +1,71 @@ +import * as Pino from 'pino' + +import { PoetNode } from '../daos/PoetNodeDao' +import { PoeAddressNotVerified, PoeBalanceInsufficient } from '../errors/errors' +import { fetchBalance } from '../helpers/ethereum' +import { Network } from '../interfaces/Network' +import { Account } from '../models/Account' + +export interface ArchiveController { + readonly postArchive: ( + account: Account, + archive: ReadableStream, + network: Network, + ) => Promise +} + +interface Arguments { + readonly dependencies: Dependencies + readonly configuration: Configuration +} + +interface Dependencies { + readonly logger: Pino.Logger + readonly mainnetNode: PoetNode + readonly testnetNode: PoetNode +} + +interface Configuration { + readonly poeContractAddress: string + readonly poeBalanceMinimum: number +} + +export const ArchiveController = ({ + dependencies: { + logger, + mainnetNode, + testnetNode, + }, + configuration: { + poeContractAddress, + poeBalanceMinimum, + }, +}: Arguments): ArchiveController => { + logger.info({ poeContractAddress, poeBalanceMinimum }, 'ArchiveController Instantiated') + + const fetchPoeBalance = fetchBalance(poeContractAddress) + + const networkToNode = (network: Network) => network === Network.LIVE ? mainnetNode : testnetNode + + const postArchive = async (account: Account, archive: ReadableStream, network: Network) => { + const node = networkToNode(network) + + const { id: userId, email, poeAddress, poeAddressVerified } = account + + logger.debug({ userId, email, poeAddress, poeAddressVerified, network }) + + if (!poeAddressVerified) + throw new PoeAddressNotVerified() + + const poeBalance = await fetchPoeBalance(poeAddress) + + if (poeBalance < poeBalanceMinimum) + throw new PoeBalanceInsufficient(poeBalanceMinimum, poeBalance) + + return node.postArchive(archive) + } + + return { + postArchive, + } +} diff --git a/src/daos/PoetNodeDao.ts b/src/daos/PoetNodeDao.ts new file mode 100644 index 000000000..eefcf0ae5 --- /dev/null +++ b/src/daos/PoetNodeDao.ts @@ -0,0 +1,27 @@ +import * as FormData from 'form-data' +import fetch from 'node-fetch' + +export interface PoetNode { + readonly postArchive: (archive: any) => Promise> +} + +export const PoetNode = (url: string): PoetNode => { + const postArchive = async (archive: any) => { + const formData = new FormData() + formData.append('content', archive, { filepath: 'content' }) + + const response = await fetch(`${url}/files`, { + method: 'post', + body: formData, + }) + + if (!response.ok) + throw new Error(await response.text()) + + return response.json() + } + + return { + postArchive, + } +} diff --git a/src/errors/errors.ts b/src/errors/errors.ts index 838721d06..4a2f7e431 100644 --- a/src/errors/errors.ts +++ b/src/errors/errors.ts @@ -89,8 +89,11 @@ export class IncorrectOldPassword extends Error { export class IncorrectToken extends Error { status = 401 - constructor(got: string, wanted: string) { - super(`Incorrect Token Type. Got "${got}", wanted "${wanted}".`) + constructor(got: string, wanted: string | string[]) { + super( + `Incorrect Token Type. Got "${got}", ` + + `wanted ${Array.isArray(wanted) ? `one of [${wanted.map(_ => `"${_}"`).join(', ')}]` : `"${wanted}"`}.`, + ) } } @@ -113,3 +116,16 @@ export class AuthenticationFailed extends Error { status = errors.AuthenticationFailed.code message = errors.AuthenticationFailed.message } + +export class PoeAddressNotVerified extends Error { + status = 403 + message = 'POE address not verified.' +} + +export class PoeBalanceInsufficient extends Error { + status = 403 + + constructor(minimum: number, balance: number) { + super(`Insufficient POE balance. You need at least ${minimum} POE. You currently have ${balance}.`) + } +} diff --git a/src/helpers/ethereum.ts b/src/helpers/ethereum.ts index e0ab984ce..5f79ec1f5 100644 --- a/src/helpers/ethereum.ts +++ b/src/helpers/ethereum.ts @@ -1,4 +1,5 @@ import { bufferToHex, ecrecover, fromRpcSig, hashPersonalMessage, publicToAddress } from 'ethereumjs-util' +import fetch from 'node-fetch' export function signatureIsValid(address: string, message: string, signature: string): boolean { if (!address || !signature) @@ -27,3 +28,7 @@ export function signatureIsValid(address: string, message: string, signature: st throw exception } } + +export const fetchBalance = (contractAddress: string) => (accountAddress: string) => + fetch(`https://api.tokenbalance.com/balance/${contractAddress}/${accountAddress}`) + .then(_ => _.json()) diff --git a/src/middlewares/authorization.ts b/src/middlewares/authorization.ts index 031848069..a49f16a90 100644 --- a/src/middlewares/authorization.ts +++ b/src/middlewares/authorization.ts @@ -2,7 +2,7 @@ import { AccountController } from '../controllers/AccountController' export const extractToken = (ctx: any): string => (ctx.header.token ? ctx.header.token : ctx.params.token) || '' -export const authorization = (accountController: AccountController) => async (ctx: any, next: any) => { +export const Authorization = (accountController: AccountController) => async (ctx: any, next: any) => { // TODO: add configuration to ctx in app.ts so middlewares have access. // This is needed until we can figure out how to restart vault between // individual tests.