dotfiles/pkgs/marvin-tracker/index.js

154 lines
4.5 KiB
JavaScript
Executable File

#!/usr/bin/env node
import http from 'node:http';
import * as mqtt from "mqtt"
import { createHash, timingSafeEqual } from "node:crypto"
const settings = {
mqtt_host: process.env.MQTT_HOST || "localhost",
mqtt_username: process.env.MQTT_USER || "guest",
mqtt_password: process.env.MQTT_PASSWORD || "guest",
topic: process.env.TOPIC || "yorick/marvin/tracking",
api_hash: process.env.API_HASH || "z6mzC2TGdVCRuFE+oCrwj1GCHyP6OzYcPKZDiO/yLdqpmChC6S7ijCEUSY5gtqhpXhtYeDRyBjNeVJ/0Se4jQQ==",
listen_port: +process.env.PORT || 3000,
listen_host: process.env.HOST || "::",
}
const client = mqtt.connect(`mqtt://${settings.mqtt_host}`, {
username: settings.mqtt_username,
password: settings.mqtt_password
});
let currently_tracking = {}
client.subscribe(settings.topic)
client.on("connect", () => {
console.log("mqtt connected")
})
client.on("message", (topic, message) => {
if (topic == settings.topic) {
try {
currently_tracking = JSON.parse(message)
} catch(e) {
console.warn("unable to parse message", msg)
}
}
})
function checkKey(password) {
if (typeof password !== "string") return false
// would be nice if this took salt
const h = createHash("blake2b512")
// salt
h.update("O9yn_qX_jz68H-B6BrkEzRGAWfInzgeOmsCajTJVwcw=")
h.update(password)
// more salt
h.update("LHVV58vOGu7pKSV_Ofmes2joHCal6-F9UuhNLvOK7HM=")
const d = h.digest()
return timingSafeEqual(Buffer.from(settings.api_hash, "base64"), d)
}
async function waitForJSON(req, max_size) {
return new Promise((resolve, reject) => {
let body = [];
let accumulatedSize = 0;
req.on('data', (chunk) => {
accumulatedSize += chunk.length;
if (accumulatedSize > max_size) {
reject({statusCode: 413, message: 'Payload too large'});
req.connection.destroy();
} else {
body.push(chunk);
}
});
req.on('end', () => {
try {
const parsedBody = JSON.parse(Buffer.concat(body).toString());
resolve(parsedBody);
} catch (e) {
reject({statusCode: 400, message: 'Bad Request'});
}
});
req.on('error', (err) => {
reject({statusCode: 500, message: 'Internal Server Error'});
});
});
}
const supported_urls = ["startTracking", "stopTracking", "markDoneTask", "deleteTask", "editTask"]
async function setTrack(track) {
track.time = Date.now()
currently_tracking = track
await client.publishAsync(settings.topic, JSON.stringify(track), {
retain: true
})
}
const endpoints = {
async startTracking(task) {
await setTrack({ task, started: true })
},
async stopTracking(task) {
if (task.done) {
await setTrack({})
} else {
await setTrack({ task, started: false })
}
},
async markDoneTask(task) {
if (task._id == currently_tracking.task?._id)
await setTrack({})
},
async deleteTask(task) {
if (task._id == currently_tracking.task?._id)
await setTrack({})
},
async editTask(task) {
if (task._id == currently_tracking.task?._id)
await setTrack({ task, started: currently_tracking.started })
}
}
// todo: check headers
async function handle(req, res) {
console.log(req.method, req.url)
if (endpoints[req.url.slice(1)]) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Marv-Key');
if (req.method == "OPTIONS") {}
else if (req.method == "POST") {
if (!checkKey(req.headers["x-marv-key"])) {
throw { statusCode: 403, message: "auth required" }
}
const body = await waitForJSON(req, 1024 * 512) // 512 kb
await endpoints[req.url.slice(1)](body)
res.end()
} else {
throw { statusCode: 405, message: "Method not supported" }
}
res.end()
} else {
throw { statusCode: 404, message: "Unknown endpoint" }
}
}
const server = http.createServer(async (req, res) => {
try {
await handle(req, res);
} catch (err) {
console.warn("error in request", err)
res.writeHead(err.statusCode || 500, {'Content-Type': 'text/plain'}).end(err.message || 'Internal Server Error');
}
})
server.listen(settings.listen_port, settings.listen_host, () => {
console.log(`Server running on http://${settings.listen_host}:${settings.listen_port}/`);
});