diff --git a/src/Units.extollo.ts b/src/Units.extollo.ts index 7b449b8..11bafc3 100644 --- a/src/Units.extollo.ts +++ b/src/Units.extollo.ts @@ -3,12 +3,14 @@ import {CommandLine} from "@extollo/cli" import {FirebaseUnit} from "./app/units/FirebaseUnit" import {Blockchain} from "./app/units/Blockchain" import {Transaction} from "./app/units/rtdb/Transaction" +import {Exposure} from "./app/units/rtdb/Exposure" export const Units = [ Config, FirebaseUnit, Blockchain, Transaction, + Exposure, Files, CommandLine, Controllers, diff --git a/src/app/FirebaseResource.ts b/src/app/FirebaseResource.ts index dc57a40..ce2ca35 100644 --- a/src/app/FirebaseResource.ts +++ b/src/app/FirebaseResource.ts @@ -1,5 +1,5 @@ import {Inject, Injectable} from "@extollo/di" -import {Collection, Iterable} from "@extollo/util" +import {collect, Collection, Iterable} from "@extollo/util" import {FirebaseUnit, RTDBRef} from "./units/FirebaseUnit" import * as firebase from "firebase-admin" import {Application} from "@extollo/lib" diff --git a/src/app/http/controllers/api/Blockchain.controller.ts b/src/app/http/controllers/api/Blockchain.controller.ts index 4f85729..5453839 100644 --- a/src/app/http/controllers/api/Blockchain.controller.ts +++ b/src/app/http/controllers/api/Blockchain.controller.ts @@ -3,6 +3,7 @@ import {Injectable, Inject} from "@extollo/di" import {TransactionResource, TransactionResourceItem} from "../../../rtdb/TransactionResource" import {many, one} from "@extollo/util" import {Blockchain as BlockchainService} from "../../../units/Blockchain" +import {ExposureResource, ExposureResourceItem} from "../../../rtdb/ExposureResource"; /** * Blockchain Controller @@ -42,4 +43,19 @@ export class Blockchain extends Controller { await ( this.make(TransactionResource)).push(item) return one(item) } + + /** + * Post a new exposure notification to the blockchain. This is only intended for testing. + */ + public async postExposure() { + const item: ExposureResourceItem = { + firebaseID: '', + seqID: -1, + clientID: String(this.request.input('clientID')), + timestamp: parseInt(String(this.request.input('timestamp'))), + } + + await ( this.make(ExposureResource)).push(item) + return one(item) + } } diff --git a/src/app/http/middlewares/api/ValidateEncounterTransaction.middleware.ts b/src/app/http/middlewares/api/ValidateEncounterTransaction.middleware.ts index 874dce8..e84b253 100644 --- a/src/app/http/middlewares/api/ValidateEncounterTransaction.middleware.ts +++ b/src/app/http/middlewares/api/ValidateEncounterTransaction.middleware.ts @@ -1,6 +1,6 @@ import {error, Middleware} from "@extollo/lib" import {Injectable} from "@extollo/di" -import {HTTPStatus} from "@extollo/util"; +import {HTTPStatus} from "@extollo/util" /** * ValidateEncounterTransaction Middleware diff --git a/src/app/http/middlewares/api/ValidateExposureTransaction.middleware.ts b/src/app/http/middlewares/api/ValidateExposureTransaction.middleware.ts new file mode 100644 index 0000000..b67f401 --- /dev/null +++ b/src/app/http/middlewares/api/ValidateExposureTransaction.middleware.ts @@ -0,0 +1,25 @@ +import {error, Middleware} from "@extollo/lib" +import {Injectable} from "@extollo/di" +import {HTTPStatus} from "@extollo/util" + +/** + * ValidateExposureTransaction Middleware + * -------------------------------------------- + * Errors out the request if it is missing any fields required to create + * a new exposure notification on the blockchain. + */ +@Injectable() +export class ValidateExposureTransaction extends Middleware { + public async apply() { + const required: string[] = [ + 'clientID', + 'timestamp', + ] + + for ( const field of required ) { + if ( !this.request.input(field) ) { + return error(`Missing required field: ${field}`, HTTPStatus.BAD_REQUEST, 'json') + } + } + } +} diff --git a/src/app/http/routes/app.routes.ts b/src/app/http/routes/app.routes.ts index 57313bb..a6978d8 100644 --- a/src/app/http/routes/app.routes.ts +++ b/src/app/http/routes/app.routes.ts @@ -5,5 +5,9 @@ Route.group('/api/v1', () => { .pre('DebugOnly') .pre('api:ValidateEncounterTransaction') + Route.post('/exposure', 'api:Blockchain.postExposure') + .pre('DebugOnly') + .pre('api:ValidateExposureTransaction') + Route.get('/chain', 'api:Blockchain.readBlockchain') }) diff --git a/src/app/rtdb/ExposureResource.ts b/src/app/rtdb/ExposureResource.ts new file mode 100644 index 0000000..4429c10 --- /dev/null +++ b/src/app/rtdb/ExposureResource.ts @@ -0,0 +1,24 @@ +import {FirebaseResource, FirebaseResourceItem} from "../FirebaseResource" +import {Injectable} from "@extollo/di" +import {RTDBRef} from "../units/FirebaseUnit" +import {AsyncCollection} from "@extollo/util" + +/** + * Interface representing a client-submitted encounter transaction. + */ +export interface ExposureResourceItem extends FirebaseResourceItem { + clientID: string; // the exposed client's ID - used as one half of the hashes + timestamp: number; // the unix-time in milliseconds when the interaction occurred +} + +/** + * A Firebase realtime-database resource for managing exposure transactions. + */ +@Injectable() +export class ExposureResource extends FirebaseResource { + public static collect(): AsyncCollection { + return new AsyncCollection(new ExposureResource()) + } + + protected refName: RTDBRef = 'exposure' +} diff --git a/src/app/units/Blockchain.ts b/src/app/units/Blockchain.ts index b2ecfbd..4ec1980 100644 --- a/src/app/units/Blockchain.ts +++ b/src/app/units/Blockchain.ts @@ -6,6 +6,7 @@ import { TransactionResourceItem } from "../rtdb/TransactionResource" import * as openpgp from "openpgp" import * as crypto from "crypto" import { collect, uuid_v4 } from "@extollo/util" +import {ExposureResourceItem} from "../rtdb/ExposureResource"; /** * Utility wrapper class for a block in the chain. @@ -177,6 +178,32 @@ export class Blockchain extends Unit { return new Block(block) } + /** + * Submit the given exposure notifications onto the blockchain. + * @param exposures + */ + public async submitExposures(...exposures: ExposureResourceItem[]) { + const lastBlock = await this.getLastBlock() + + this.logging.verbose('Last block:') + this.logging.verbose(lastBlock) + + const block: BlockResourceItem = { + timestamp: (new Date).getTime(), + uuid: uuid_v4(), + transactions: exposures, + lastBlockHash: lastBlock!.hash(), + lastBlockUUID: lastBlock!.uuid, + proof: await this.generateProofOfWork(lastBlock), + + firebaseID: '', + seqID: -1, + } + + await (this.app().make(BlockResource)).push(block) + return new Block(block) + } + /** * Instantiate the genesis block of the entire chain. */ diff --git a/src/app/units/rtdb/Exposure.ts b/src/app/units/rtdb/Exposure.ts new file mode 100644 index 0000000..2979851 --- /dev/null +++ b/src/app/units/rtdb/Exposure.ts @@ -0,0 +1,72 @@ +import { FirebaseUnit } from "../FirebaseUnit" +import { Singleton, Inject } from "@extollo/di" +import { Unit, Logging } from "@extollo/lib" +import { Blockchain } from "../Blockchain" +import { ExposureResource, ExposureResourceItem } from "../../rtdb/ExposureResource" + +/** + * Exposure Unit + * --------------------------------------- + * This unit listens for exposure notifications created on the realtime database. + * When new ones come through, it validates them, and pushes them onto this + * server's blockchain. + */ +@Singleton() +export class Exposure extends Unit { + /** True if currently processing transactions. */ + private processing: boolean = false + + @Inject() + protected readonly firebase!: FirebaseUnit + + @Inject() + protected readonly blockchain!: Blockchain + + @Inject() + protected readonly logging!: Logging + + /** Claim the right to process transactions. Returns true if the right was granted. */ + claim() { + if ( !this.processing ) { + this.processing = true + return true + } + + return false + } + + /** Release the right to claim transactions. */ + release() { + this.processing = false + } + + /** + * Subscribe to the transactions reference and wait for new transactions to be added. + */ + public async up() { + this.firebase.ref('exposure').on('child_added', async (snapshot) => { + this.logging.debug('Received child_added event for exposures reference.') + if ( !this.claim() ) return + await this.firebase.trylock('block') + + const exposure: ExposureResourceItem = snapshot.val() + + // Push the exposure transactions onto the chain + await this.blockchain.submitExposures(exposure) + + if ( snapshot.key ) + await ( this.make(ExposureResource)).ref().child(snapshot.key).remove() + + this.release() + await this.firebase.unlock('block') + }) + } + + /** + * Release listeners and resources before shutdown. + */ + public async down() { + // Release all subscriptions before shutdown + this.firebase.ref("transaction").off() + } +}