This commit is contained in:
Overtorment 2020-07-12 22:08:18 +01:00
commit 6e32b61bb7
16 changed files with 2144 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.idea/
.vscode/
node_modules/
build/
tmp/
temp/
.env

12
README.md Normal file
View File

@ -0,0 +1,12 @@
# Ground Control
```shell script
npm install -g dtsgenerator
dtsgen openapi.yaml > src/openapi/api.ts
npm i
npm start
```

137
openapi.yaml Normal file
View File

@ -0,0 +1,137 @@
openapi: 3.0.0
info:
title: GroundControl push server API
description: Push notifications server for BlueWallet
version: 0.0.3
servers:
- url: http://localhost:3001
paths:
/lightningInvoiceGotSettled:
post:
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LightningInvoiceSettledNotification'
responses:
'200':
description: OK
/majorTomToGroundControl:
post:
summary: "Associate bitcoin addressess / ln preimage hashes that you wish to be notified about to specific push token. Token serves as unique identifier of a device/user. Also, OS of the token"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
addresses:
type: array
items:
type: string
hashes:
type: array
items:
type: string
token:
type: string
os:
type: string
responses:
'201':
description: Created
/ping:
get:
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/ServerInfo'
components:
schemas:
ServerInfo:
type: "object"
properties:
name:
type: "string"
description:
type: "string"
version:
type: "string"
uptime:
type: "number"
LightningInvoiceSettledNotification:
description: object thats posted to GroundControl to notify end-user that his specific invoice was paid by someone
type: "object"
properties:
memo:
type: "string"
description: "text that was embedded in invoice paid"
preimage:
type: "string"
description: "hex string preimage"
hash:
type: "string"
description: "hex string preimage hash"
amt_paid_sat:
type: "number"
description: "exactly how much satoshis was paid to make this invoice settked (>= invoice amount)"
PushNotification:
description: payload for push notification delivered to phone
type: object
required:
- type
- os
- token
properties:
"type":
type: "integer"
enum:
- 1
- 2
"token":
type: "string"
"os":
type: "string"
enum:
- "android"
description: >
type:
* `1` - Your lightning invoice was paid
* `2` - New transaction to one of your addresses
badge:
type: "integer"
PushNotificationLightningInvoicePaid:
allOf: # Combines PushNotification and the inline model
- $ref: '#/components/schemas/PushNotification'
- type: object
required:
- sat
- hash
- memo
properties:
type:
type: "integer"
enum: [1]
sat:
type: "integer"
description: amount of satoshis
hash:
type: "string"
description: hash of specific ln invoice preimage
memo:
type: "string"
description: text attached to bolt11

1460
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "groundcontrol",
"version": "0.0.4",
"description": "GroundControl push server API",
"devDependencies": {},
"dependencies": {
"@types/node": "^8.0.29",
"body-parser": "^1.18.1",
"cors": "^2.8.5",
"dotenv": "^8.2.0",
"express": "^4.15.4",
"express-rate-limit": "^5.1.3",
"frisbee": "^3.1.4",
"mysql": "^2.14.1",
"reflect-metadata": "^0.1.10",
"ts-node": "3.3.0",
"typeorm": "0.2.25",
"typescript": "3.3.3333"
},
"scripts": {
"start": "ts-node src/index.ts"
}
}

65
src/class/pusher.ts Normal file
View File

@ -0,0 +1,65 @@
import '../openapi/api';
import {getRepository} from "typeorm";
import {PushLog} from "../entity/PushLog";
import {TokenToAddress} from "../entity/TokenToAddress";
const Frisbee = require('frisbee');
/**
* Since we cant attach any code to openapi schema definition, this is a repository of transforming pushnotification object
* (thats created from apenapi schema) to actual payload thats gona be pushed to fcm/apns. In most basic case we would
* need to only fill title/body according to user language.
*
* One method per each notification type.
*/
export class Pusher {
static async pushLightningInvoicePaid(serverKey: string, pushNotification: Components.Schemas.PushNotificationLightningInvoicePaid) : Promise <[object, object]> {
const fcmPayload = {
"data" : {},
"notification" : {
"body" : 'Paid: ' + (pushNotification.memo || 'your invoice'),
"title": '+' + pushNotification.sat + ' sats',
"badge": pushNotification.badge,
}
};
return Pusher._pushToFcm(serverKey, pushNotification.token, fcmPayload, pushNotification);
}
protected static async _pushToFcm(serverKey: string, token: string, fcmPayload: object, pushNotification: Components.Schemas.PushNotification) : Promise <[object, object]> {
const _api = new Frisbee({ baseURI: 'https://fcm.googleapis.com' });
fcmPayload['to'] = token;
fcmPayload['priority'] = 'high';
// now, we pass some of the notification properties as data properties to FCM payload:
for (let dataKey of Object.keys(pushNotification)) {
if (['token', 'os', 'badge'].includes(dataKey)) continue;
fcmPayload['data'][dataKey] = pushNotification[dataKey];
}
const apiResponse = await _api.post(
'/fcm/send',
Object.assign(
{},
{
headers: {
'Authorization': 'key=' + serverKey,
'Content-Type': 'application/json',
'Host': 'fcm.googleapis.com',
},
body: fcmPayload,
},
),
);
let responseJson = {};
if (typeof apiResponse.body === 'object') responseJson = apiResponse.body;
delete fcmPayload['to']; // compacting a bit, we dont need token in payload as well
const PushLogRepository = getRepository(PushLog);
await PushLogRepository.save( { token: token, payload: JSON.stringify(fcmPayload), success: !!responseJson['success'] });
return [fcmPayload, responseJson];
}
}

View File

@ -0,0 +1,98 @@
import '../openapi/api';
import {getRepository} from "typeorm";
import {NextFunction, Request, Response} from "express";
import {User} from "../entity/User";
import {TokenToAddress} from "../entity/TokenToAddress";
import { TokenToHash } from "../entity/TokenToHash";
import {Pusher} from "../class/pusher";
const pck = require('../../package.json');
export class GroundControl {
private tokenToAddressRepository = getRepository(TokenToAddress);
private tokenToHashRepository = getRepository(TokenToHash);
/**
* Submit bitcoin addressess that you wish to be notified about to specific push token. Token serves as unique identifier of a device/user. Also, OS of the token
*
* @param request
* @param response
* @param next
*/
async majorTomToGroundControl(request: Request, response: Response, next: NextFunction) {
const body: Paths.MajorTomToGroundControl.Post.RequestBody = request.body;
// todo: checks that we are receiving data and that there are not too much records in it (probably 1000 addresses for a start is enough)
if (!body.addresses || !Array.isArray(body.addresses)) {
response.status(500).send('addresses not provided');
return;
}
if (!body.hashes || !Array.isArray(body.hashes)) {
response.status(500).send('hashes not provided');
return;
}
if (!body.token || !body.os) {
response.status(500).send('token not provided');
return;
}
// todo: refactor into single batch save
for (const address of body.addresses) {
// todo: validate bitcoin address
console.log(body.token, '->', address);
try {
await this.tokenToAddressRepository.save({address, token: body.token, os: body.os});
} catch (_) {}
}
// todo: refactor into single batch save
for (const hash of body.hashes) {
// todo: validate hash
console.log(body.token, '->', hash);
try {
await this.tokenToHashRepository.save({ hash, token: body.token, os: body.os});
} catch (_) {}
}
response.status(201).send('');
}
async lightningInvoiceGotSettled(request: Request, response: Response, next: NextFunction) {
const body: Paths.LightningInvoiceGotSettled.Post.RequestBody = request.body;
// todo: check preimage and hash, lookup token in the db table, and actually send push request
const hashShouldBe = require('crypto').createHash('sha256').update(Buffer.from(body.preimage, 'hex')).digest('hex');
if (hashShouldBe !== body.hash) {
response.status(500).send('preimage doesnt match hash');
return;
}
const tokenToHash = await this.tokenToHashRepository.findOne({ hash: hashShouldBe });
const serverKey = process.env.FCM_SERVER_KEY;
if (tokenToHash && serverKey) {
console.warn('pushing to token', tokenToHash.token);
const pushNotification: Components.Schemas.PushNotificationLightningInvoicePaid = {
sat: body.amt_paid_sat,
badge: 1,
type: 1,
os: 'android', // tokenToHash.os
token: tokenToHash.token,
hash: hashShouldBe,
memo: body.memo,
};
await Pusher.pushLightningInvoicePaid(serverKey, pushNotification);
}
response.status(200).send('');
}
async ping(request: Request, response: Response, next: NextFunction) {
const serverInfo: Paths.Ping.Get.Responses.$200 = {
description: pck.description,
version: pck.version,
uptime: 666
};
return serverInfo;
}
}

View File

@ -0,0 +1,26 @@
import {getRepository} from "typeorm";
import {NextFunction, Request, Response} from "express";
import {User} from "../entity/User";
export class UserController {
private userRepository = getRepository(User);
async all(request: Request, response: Response, next: NextFunction) {
return this.userRepository.find();
}
async one(request: Request, response: Response, next: NextFunction) {
return this.userRepository.findOne(request.params.id);
}
async save(request: Request, response: Response, next: NextFunction) {
return this.userRepository.save(request.body);
}
async remove(request: Request, response: Response, next: NextFunction) {
let userToRemove = await this.userRepository.findOne(request.params.id);
await this.userRepository.remove(userToRemove);
}
}

21
src/entity/PushLog.ts Normal file
View File

@ -0,0 +1,21 @@
import {Entity, PrimaryGeneratedColumn, Column, Index} from "typeorm";
@Entity()
@Index(["token"], { unique: false })
export class PushLog {
@PrimaryGeneratedColumn()
id: number;
@Column()
token: string;
@Column()
payload: string;
@Column()
success: boolean;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP'})
created: Date;
}

View File

@ -0,0 +1,21 @@
import {Entity, PrimaryGeneratedColumn, Column, Index} from "typeorm";
@Entity()
@Index(["token", "address"], { unique: true })
export class TokenToAddress {
@PrimaryGeneratedColumn()
id: number;
@Column()
token: string;
@Column()
os: string;
@Column()
address: string;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP'})
created: Date;
}

21
src/entity/TokenToHash.ts Normal file
View File

@ -0,0 +1,21 @@
import {Entity, PrimaryGeneratedColumn, Column, Index} from "typeorm";
@Entity()
@Index(["token", "hash"], { unique: true })
export class TokenToHash {
@PrimaryGeneratedColumn()
id: number;
@Column()
token: string;
@Column()
os: string;
@Column()
hash: string;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP'})
created: Date;
}

18
src/entity/User.ts Normal file
View File

@ -0,0 +1,18 @@
import {Entity, PrimaryGeneratedColumn, Column} from "typeorm";
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
firstName: string;
@Column()
lastName: string;
@Column()
age: number;
}

69
src/index.ts Normal file
View File

@ -0,0 +1,69 @@
import "reflect-metadata";
import {createConnection} from "typeorm";
import * as express from "express";
import * as bodyParser from "body-parser";
import {Request, Response} from "express";
import {Routes} from "./routes";
require('dotenv').config();
const cors = require('cors')
const url = require("url");
const parsed = url.parse(process.env.JAWSDB_MARIA_URL);
createConnection(
{
"type": "mariadb",
"host": parsed.hostname,
"port": parsed.port,
"username": parsed.auth.split(":")[0],
"password": parsed.auth.split(":")[1],
"database": parsed.path.replace("/", ""),
"synchronize": true,
"logging": false,
"entities": [
"src/entity/**/*.ts"
],
"migrations": [
"src/migration/**/*.ts"
],
"subscribers": [
"src/subscriber/**/*.ts"
],
"cli": {
"entitiesDir": "src/entity",
"migrationsDir": "src/migration",
"subscribersDir": "src/subscriber"
}
}
).then(async connection => {
// create express app
const app = express();
app.use(bodyParser.json());
app.use(cors());
// register express routes from defined application routes
Routes.forEach(route => {
(app as any)[route.method](route.route, (req: Request, res: Response, next: Function) => {
const result = (new (route.controller as any))[route.action](req, res, next);
if (result instanceof Promise) {
result.then(result => result !== null && result !== undefined ? res.send(result) : undefined);
} else if (result !== null && result !== undefined) {
res.json(result);
}
});
});
app.set("trust proxy", 1);
const rateLimit = require("express-rate-limit");
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
});
app.use(limiter);
app.listen(process.env.PORT || 3001);
console.log("Express server has started on port ", (process.env.PORT || 3001));
}).catch(error => console.log(error));

106
src/openapi/api.ts Normal file
View File

@ -0,0 +1,106 @@
declare namespace Components {
namespace Schemas {
/**
* object thats posted to GroundControl to notify end-user that his specific invoice was paid by someone
*/
export interface LightningInvoiceSettledNotification {
/**
* text that was embedded in invoice paid
*/
memo?: string;
/**
* hex string preimage
*/
preimage?: string;
/**
* hex string preimage hash
*/
hash?: string;
/**
* exactly how much satoshis was paid to make this invoice settked (>= invoice amount)
*/
amt_paid_sat?: number;
}
/**
* payload for push notification delivered to phone
*/
export interface PushNotification {
type: 1 | 2;
token: string;
/**
* type:
* * `1` - Your lightning invoice was paid
* * `2` - New transaction to one of your addresses
*
*/
os: "android";
badge?: number;
}
/**
* payload for push notification delivered to phone
*/
export interface PushNotificationLightningInvoicePaid {
type: 1;
token: string;
/**
* type:
* * `1` - Your lightning invoice was paid
* * `2` - New transaction to one of your addresses
*
*/
os: "android";
badge?: number;
/**
* amount of satoshis
*/
sat: number;
/**
* hash of specific ln invoice preimage
*/
hash: string;
/**
* text attached to bolt11
*/
memo: string;
}
export interface ServerInfo {
name?: string;
description?: string;
version?: string;
uptime?: number;
}
}
}
declare namespace Paths {
namespace LightningInvoiceGotSettled {
namespace Post {
export type RequestBody = /* object thats posted to GroundControl to notify end-user that his specific invoice was paid by someone */ Components.Schemas.LightningInvoiceSettledNotification;
namespace Responses {
export interface $200 {
}
}
}
}
namespace MajorTomToGroundControl {
namespace Post {
export interface RequestBody {
addresses?: string[];
hashes?: string[];
token?: string;
os?: string;
}
namespace Responses {
export interface $201 {
}
}
}
}
namespace Ping {
namespace Get {
namespace Responses {
export type $200 = Components.Schemas.ServerInfo;
}
}
}
}

45
src/routes.ts Normal file
View File

@ -0,0 +1,45 @@
import {UserController} from "./controller/UserController";
import {GroundControl} from "./controller/GroundControl";
export const Routes = [{
method: "get",
route: "/users",
controller: UserController,
action: "all"
}, {
method: "get",
route: "/users/:id",
controller: UserController,
action: "one"
}, {
method: "post",
route: "/users",
controller: UserController,
action: "save"
}, {
method: "delete",
route: "/users/:id",
controller: UserController,
action: "remove"
}
,{
method: "post",
route: "/majorTomToGroundControl",
controller: GroundControl,
action: "majorTomToGroundControl"
},{
method: "post",
route: "/lightningInvoiceGotSettled",
controller: GroundControl,
action: "lightningInvoiceGotSettled"
},{
method: "get",
route: "/ping",
controller: GroundControl,
action: "ping"
}
];

15
tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"lib": [
"es5",
"es6"
],
"target": "es5",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "./build",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true
}
}