HerrHase 5 days ago
parent 243a0c159c
commit bd65cdfcc2

8
.gitignore vendored

@ -130,3 +130,11 @@ dist
.yarn/install-state.gz
.pnp.*
storage/*
!storage/logs/.gitkeep
!storage/.gitkeep
resources/actions/*
resources/schemas/*
!resources/actions/.gitkeep
!resources/schemas/.gitkeep

@ -1,2 +1,25 @@
# signpost
# Signpost
Simple fast secure Webhook handling Service. Add Flows that handle Actions-Classes
written in Javascript. Each Flow is secured by an Token. Optional you can add
an JsonSchema for the Flow to validated incoming Data.
## install
```
npm install
npm run migrate --workspace=server
npm run start --workspace=server
```
## add flow
```
npm run addFlow
```
### action
A Class for that have only the async method run. Each Object of class
has the current flow, the requested body and optional a JsonSchema. The Class
must be placed.

@ -0,0 +1,78 @@
import { client, xml } from '@xmpp/client'
import debug from '@xmpp/debug'
import DOMPurify from 'isomorphic-dompurify'
import Action from './../../packages/server/actions/action.js'
import logger from './../../packages/server/helper/logger.js'
/**
* using a xmpp-server to send messages in a group
*
*
* @author Björn Hase <me@herr-hase.wtf>
* @license hhttps://www.gnu.org/licenses/gpl-3.0.en.html GPL-3
* @link https://git.node001.net/HerrHase/signpost.git
*
*/
class Xmpp extends Action {
/**
* run
*
*/
async run() {
// cleaning data
this._sanitize()
const xmpp = client({
service: process.env.XMPP_SERVICE,
domain: process.env.XMPP_DOMAIN,
username: process.env.XMPP_USERNAME,
password: process.env.XMPP_PASSWORD
})
if (process.env.APP_DEBUG) {
debug(xmpp, true)
}
// handle if client has errors
xmpp.on('error', (error) => {
logger(this.flow.uuid).error('xmpp / error ' + error)
})
// handle if client goes online
xmpp.once('online', async (address) => {
// join group
await xmpp.send(xml('presence', { to: process.env.XMPP_ROOM + '/' + process.env.XMPP_USERNAME }, xml('x', { xmlns: 'http://jabber.org/protocol/muc' })))
// sends a message to the room
const message = xml(
'message', {
type: 'groupchat',
to: process.env.XMPP_ROOM
},
xml('body', {}, this.data.message)
)
await xmpp.send(message)
logger(this.flow.uuid).info('xmpp / message send')
await xmpp.disconnect()
})
await xmpp.start()
}
/**
* clearing data from any malicous code
*
*/
_sanitize() {
this.data.message = DOMPurify.sanitize(this.data.message)
}
}
export default Xmpp

2681
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,8 @@
{
"name": "signpost",
"version": "0.1.0",
"private": true,
"workspaces": [
"packages/*"
]
}

@ -0,0 +1,40 @@
import fastify from 'fastify'
import getOrCreateSqlite from './db/sqlite.js'
// get config
import config from './_config.js'
config()
// create server
const server = fastify()
// add rate limit
import rateLimit from '@fastify/rate-limit'
server.register(rateLimit, {
max: process.env.APP_RATE_LIMIT_MAX,
timeWindow: process.env.APP_RATE_LIMIT_TIMEWINDOW
})
/**
*
*
*/
server.decorate('db', () => {
return getOrCreateSqlite({ 'uri': process.env.APP_SQLITE_URI_MAIN, 'create': true, 'readwrite': true })
})
/**
* routing
*
*/
import flowHttp from './http/api/flow.js'
server
.register(flowHttp, {
'prefix': '/api/flow/v1'
})
export default server

@ -0,0 +1,28 @@
import path from 'path'
import dotenv from 'dotenv'
/**
* getting emv amd merge with basic values
*
*
*/
function config() {
// getting dir
const server_dir = path.resolve()
const base_dir = path.join(server_dir, '/../..')
dotenv.config({ path: base_dir + '/.env' })
const config = {
APP_BASE_DIR: base_dir,
APP_SERVER_DIR: server_dir,
APP_SQLITE_URI_MAIN: path.join(base_dir, '/storage/main.db'),
APP_HASH_TYPE: 'sha512'
}
// merge
dotenv.populate(process.env, config)
}
export default config

@ -0,0 +1,25 @@
/**
* Action
*
* @author Björn Hase <me@herr-hase.wtf>
* @license hhttps://www.gnu.org/licenses/gpl-3.0.en.html GPL-3
* @link https://git.node001.net/HerrHase/signpost.git
*
*/
class Action {
/**
*
*
*/
constructor(flow, data) {
this.flow = flow
this.data = data
if (!this.run()) {
throw new Error('run method needed')
}
}
}
export default Action

@ -0,0 +1,58 @@
import { input, password } from '@inquirer/prompts'
import { v4 as uuidv4 } from 'uuid'
import path from 'path'
import chalk from 'chalk'
import crypto from 'node:crypto'
import runMigrationSqlite from './../db/migration.js'
import getOrCreateSqlite from './../db/sqlite.js'
import TokenHelper from './../helper/token.js'
// getting flow
import FlowStore from './../store/flow.js'
// get config
import config from './../_config.js'
config()
// getting db and flowStore
const mainDB = getOrCreateSqlite({ 'uri': process.env.APP_SQLITE_URI_MAIN, 'create': true, 'readwrite': true })
const flowStore = new FlowStore(mainDB)
const token = crypto.randomBytes(64).toString('base64')
const flow = {
'hash' : TokenHelper.create(token)
}
flow.uuid = uuidv4()
// adding action
flow.action = await input({
message: 'action:',
async validate(value) {
if (!value) {
return 'Required!'
}
return true
}
})
// adding schema
flow.schema = await input({
message: 'schema:'
})
// adding description
flow.description = await input({
message: 'description:'
})
const id = flowStore.create(flow, token)
if (id) {
console.log(chalk.green('done'))
console.log(chalk.blue('uuid: ' + flow.uuid))
console.log(chalk.blue('token: ' + token))
}

@ -0,0 +1,10 @@
import runMigrationSqlite from './../db/migration.js'
import getOrCreateSqlite from './../db/sqlite.js'
import path from 'path'
// get config
import config from './../_config.js'
config()
const mainDB = getOrCreateSqlite({ 'uri': process.env.APP_SQLITE_URI_MAIN, 'create': true, 'readwrite': true })
runMigrationSqlite(path.join(path.resolve(), './../server/migration'), mainDB)

@ -0,0 +1,24 @@
import { input, password } from '@inquirer/prompts'
import { v4 as uuidv4 } from 'uuid';
import { getOrCreateSqlite } from '@nano/sqlite'
import path from 'path'
import FlowStore from './../store/flow.js'
// getting db and flowStore
const mainDB = getOrCreateSqlite({ 'uri': process.env.APP_SQLITE_URI_MAIN, 'create': true, 'readwrite': true })
const flowStore = new FlowStore(mainDB)
// adding action
const uuid = await input({
message: 'uuid:',
async validate(value) {
if (!value) {
return 'Required!'
}
return true
}
})
flowStore.deleteByUuid(uuid)

@ -0,0 +1,41 @@
import { readdir, readFile } from 'node:fs/promises'
import path from 'path'
/**
* runMigrationSqlite
*
* @param {string} path
* @param {object} db
*
*/
async function runMigrationSqlite(filePath, db) {
const SQL_EXTENSION = '.sql'
// getting files
const files = await readdir(filePath)
for (const fileName of files) {
// skip if extension not sql
if (path.extname(fileName).toLowerCase() !== SQL_EXTENSION) {
continue
}
const sql = await readFile(filePath + '/' + fileName, 'utf8')
try {
if (!sql.trim()) {
throw new Error(fileName + ' is empty!')
}
db.prepare(sql.trim()).run()
console.log('migrated ' + path.basename(fileName, SQL_EXTENSION))
} catch(error) {
console.log(error)
}
}
}
export default runMigrationSqlite

@ -0,0 +1,41 @@
/**
* db
*
* @author Björn Hase <me@herr-hase.wtf>
* @license hhttps://www.gnu.org/licenses/gpl-3.0.en.html GPL-3
* @link https://git.node001.net/HerrHase/signpost.git
*
*/
import Database from 'better-sqlite3'
function getOrCreateSqlite(options = {}) {
let db
options = Object.assign({
uri: process.env.NANO_SQLITE_PATH,
wal: true
}, options)
const uri = options.uri
delete options.uri
const wal = options.wal
delete options.wal
//
if (Object.keys(options).length === 0) {
db = new Database(uri)
} else {
db = new Database(uri, options)
}
if (wal) {
db.pragma('journal_mode = WAL')
}
return db
}
export default getOrCreateSqlite

@ -0,0 +1,53 @@
import { resolveActionClass, resolveSchema } from './../helper/resolver.js'
import FlowStore from './../store/flow.js'
import logger from './../helper/logger.js'
/**
*
* handle parser
*
*
* @author Björn Hase <me@herr-hase.wtf>
* @license hhttps://www.gnu.org/licenses/gpl-3.0.en.html GPL-3
* @link https://git.node001.net/HerrHase/signpost.git
*
*/
async function flowHandler(request, response) {
// get single flow
const flowStore = new FlowStore(this.db())
const flow = await flowStore.findOneByUuid(request.params.uuid)
if (!flow) {
return response
.code(404)
.send()
}
// add to locals
response.locals = {
'flow' : flow
}
// getting action class
try {
response.locals.action = await import(resolveActionClass(flow.action))
} catch(error) {
logger(flow.uuid).error('flowHandler / resolve class / ' + error)
return response
.code(500)
.send()
}
// check for schema
if (flow.schema) {
try {
response.locals.schema = await import(resolveSchema(flow.schema))
} catch(error) {
logger(flow.uuid).error('flowHandler / resolve schema / ' + error)
}
}
}
export default flowHandler

@ -0,0 +1,44 @@
import DOMPurify from 'isomorphic-dompurify'
import TokenHelper from './../helper/token.js'
import logger from './../helper/logger.js'
/**
* handle token
*
* @author Björn Hase <me@herr-hase.wtf>
* @license hhttps://www.gnu.org/licenses/gpl-3.0.en.html GPL-3
* @link https://git.node001.net/HerrHase/signpost.git
*
*/
async function tokenHandler(request, response) {
if (!request.headers.authorization) {
return response
.code(403)
.send()
}
let token = DOMPurify.sanitize(request.headers.authorization)
token = token.match(/^Bearer ([A-Za-z0-9._~+/-]+=*)$/)
// check if token exists
if (!token[1]) {
logger(response.locals.flow.uuid).error('token not found in header')
return response
.code(403)
.send()
}
// check if token is same as for the flow
if (!TokenHelper.equal(token[1], response.locals.flow.hash)) {
logger(response.locals.flow.uuid).error('token not equal with hash from flow')
return response
.code(403)
.send()
}
}
export default tokenHandler

@ -0,0 +1,35 @@
import pino from 'pino'
import pretty from 'pino-pretty'
import dayjs from 'dayjs'
import { mkdirSync } from "node:fs"
/**
* Wrapper for logger
*
* @author Björn Hase <me@herr-hase.wtf>
* @license hhttps://www.gnu.org/licenses/gpl-3.0.en.html GPL-3
* @link https://git.node001.net/HerrHase/signpost.git
*
*/
function logger(uuid) {
let destination = process.env.APP_BASE_DIR + '/storage/logs/'
// if name is set, adding name as directory
if (uuid) {
destination += uuid + '/'
mkdirSync(destination, { recursive: true })
}
destination += dayjs().format('DD-MM-YYYY') + '.log'
return pino({
timestamp: () => {
return `, "time":"${new Date(Date.now()).toISOString()}"`
},
},
pino.destination(destination)
)
}
export default logger

@ -0,0 +1,54 @@
import fs from 'fs'
import path from 'path'
/**
* resolve action class
*
* @author Björn Hase <me@herr-hase.wtf>
* @license hhttps://www.gnu.org/licenses/gpl-3.0.en.html GPL-3
* @link https://git.node001.net/HerrHase/signpost.git
*
*/
function resolveActionClass(className) {
let classPath = path.join(process.env.APP_BASE_DIR, 'resources/actions/' + className + '.js')
let result = undefined
if (fs.existsSync(classPath)) {
result = classPath
}
if (!result) {
throw new Error('Action Class ' + className + ' / ' + classPath + ' not found!')
}
return result
}
/**
* resolve schema
*
* @author Björn Hase <me@herr-hase.wtf>
* @link https://git.node001.net/HerrHase/signpost.git
*
*/
function resolveSchema(schemaName) {
let schemaPath = path.join(process.env.APP_BASE_DIR, 'resources/schemas/' + schemaName + '.json')
let result = undefined
if (fs.existsSync(schemaPath)) {
result = schemaPath
}
if (!result) {
throw new Error('Schema ' + schemaName + ' / ' + schemaPath + ' not found!')
}
return result
}
export {
resolveActionClass,
resolveSchema
}

@ -0,0 +1,42 @@
import { createHmac, randomBytes } from 'node:crypto'
/**
* TokenHelper
*
* @author Björn Hase <me@herr-hase.wtf>
* @license hhttps://www.gnu.org/licenses/gpl-3.0.en.html GPL-3
* @link https://git.node001.net/HerrHase/signpost.git
*
*/
const TokenHelper = {
/**
* create hash from token
*
* @param string token
*
* @return string
*
*/
create(token) {
const hmac = createHmac(process.env.APP_HASH_TYPE, process.env.APP_SALT)
const buffer = new Buffer(token)
return hmac.update(buffer).digest('base64')
},
/**
* check if token and hash are equal
*
* @param string token
* @param string hash
*
* @return boolean
*
*/
equal(token, hash) {
return TokenHelper.create(token) === hash
}
}
export default TokenHelper

@ -0,0 +1,48 @@
import tokenHandler from './../../handler/token.js'
import flowHandler from './../../handler/flow.js'
import logger from './../../helper/logger.js'
/**
* handle webhook
*
* @author Björn Hase <me@herr-hase.wtf>
* @license hhttps://www.gnu.org/licenses/gpl-3.0.en.html GPL-3
* @link https://git.node001.net/HerrHase/signpost.git
*
*/
export default async function(fastify, options) {
fastify.addHook('preHandler', flowHandler)
fastify.addHook('preHandler', tokenHandler)
/**
*
*
* @param {object} request
* @param {object} response
*
*/
fastify.post('/:uuid(^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$)', async function (request, response) {
if (response.locals.schema && !request.validateInput(request.body, response.locals.schema)) {
return response
.code(400)
.send()
}
// running actions
const action = new response.locals.action.default(response.locals.flow, request.body)
response
.code(204)
.send()
// try run from action
try {
await action.run()
} catch(error) {
logger(response.locals.flow.uuid).error('webhook / run / ' + error)
}
})
}

@ -0,0 +1,19 @@
import server from './_bootstrap.js'
// let it rain
const start = async () => {
try {
await server.listen({
port: process.env.APP_PORT
})
console.log('Server: start')
console.log('Server: running on port ' + process.env.APP_PORT)
} catch (error) {
console.log(error)
process.exit(1)
}
}
start()

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS flows (
id INTEGER PRIMARY KEY,
uuid TEXT NOT NULL UNIQUE,
hash TEXT NOT NULL UNIQUE,
description TEXT,
action TEXT NOT NULL,
schema TEXT,
date_created_at TEXT
)

@ -0,0 +1,28 @@
{
"private": true,
"name": "server",
"version": "0.1.0",
"type": "module",
"dependencies": {
"@fastify/rate-limit": "^10.3.0",
"@inquirer/prompts": "^8.3.0",
"@xmpp/client": "^0.14.0",
"better-sqlite3": "^12.6.2",
"chalk": "^5.6.2",
"dayjs": "^1.11.19",
"dotenv": "^17.3.1",
"fastify": "^5.7.4",
"isomorphic-dompurify": "^3.0.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"uuid": "^13.0.0"
},
"devDependencies": {
"@xmpp/debug": "^0.14.0"
},
"scripts": {
"start": "node index.js",
"migrate": "node cli/migrate.js",
"addFlow": "node cli/addFlow.js"
}
}

@ -0,0 +1,37 @@
import dayjs from 'dayjs'
import Store from './store.js'
/**
* Store for Flow
*
* @author Björn Hase <me@herr-hase.wtf>
* @license hhttps://www.gnu.org/licenses/gpl-3.0.en.html GPL-3
* @link https://git.node001.net/HerrHase/signpost.git
*
*/
class FlowStore extends Store {
constructor(db) {
super(db, 'flows')
}
/**
*
*
*/
create(data, hash) {
data.date_created_at = dayjs().toISOString()
return super.create(data)
}
/**
*
*
*/
findOneByUuid(uuid) {
return this._db.prepare('SELECT * FROM ' + this._tableName + ' WHERE uuid = ?')
.get(uuid)
}
}
export default FlowStore

@ -0,0 +1,108 @@
/**
* store - abstract class
*
* @author Björn Hase <me@herr-hase.wtf>
* @license hhttps://www.gnu.org/licenses/gpl-3.0.en.html GPL-3
* @link https://git.node001.net/HerrHase/signpost.git
*
*/
class Store {
/**
*
*
* @param {tableName}
*
*/
constructor(db, tableName) {
this._db = db
this._tableName = tableName
}
/**
* create row
*
*/
findOneById(id) {
return this.db
.prepare('SELECT * FROM ' + this._tableName + ' WHERE id = ?')
.run(id)
}
/**
* create row
*
*/
remove(id) {
this.db
.prepare('DELETE FROM ' + this._tableName + ' WHERE id = ?')
.run(id)
}
/**
*
*
*/
update(id, data) {
return this._db.prepare('UPDATE ' + this._tableName + ' SET ' + this._prepareUpdateBinding(data) + ' WHERE id = ?')
.run(id, data)
}
/**
*
*
*/
create(data) {
return this._db.prepare('INSERT INTO ' + this._tableName + ' (' + Object.keys(data).join(', ') + ') VALUES (' + this._prepareInsertBinding(data) + ')')
.run(data)
}
/**
* run through object and add key to string for
* binding parameters for insert
*
* @param {object} data
* @return {string}
*/
_prepareInsertBinding(data) {
let result = ''
let length = Object.keys(data).length
let index = 0
for (const key in data) {
result += '@' + key
if (index++ < (length - 1)) {
result += ', '
}
}
return result
}
/**
* run through object and add key to string for
* binding parameters for update
*
* @param {object} data
* @return {string}
*/
_prepareUpdateBinding(data) {
let result = ''
let length = Object.keys(data).length
let index = 0
for (const key in data) {
result += key + ' = @' + key
if (index++ < (length - 1)) {
result += ', '
}
}
return result
}
}
export default Store

@ -0,0 +1,14 @@
import {describe, it} from 'node:test'
import assert from 'node:assert'
import config from './../_config.js'
config()
import { resolveActionClass } from './../helper/resolver.js'
describe('helper / resolver', () => {
it('should get a action class', () => {
const action = resolveActionClass('test')
assert.match(action, /test.js/)
})
})
Loading…
Cancel
Save