From c50ea6fa9bdb263bf05d055ff9a249b0e67f1b4d Mon Sep 17 00:00:00 2001 From: Jordan Hotmann Date: Thu, 26 May 2022 15:54:32 -0700 Subject: [PATCH] MQTT and HTTP can both be used at same time --- README.md | 2 +- bin/www | 14 +++++++++ src/models/Device.js | 63 +++++++++++++++++++++++-------------- src/models/User.js | 11 ++++++- src/mqtt.js | 18 +++++++---- src/routes/pub.js | 25 +++++++++++++-- src/routes/user.js | 4 +++ src/views/navbar.html | 7 +++++ src/views/user-devices.html | 9 ++++++ src/views/user-help.html | 4 +-- src/views/user.html | 4 ++- 11 files changed, 124 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 5834f5a..fd8ee83 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The recommended and supported installation method is using [Docker](https://www. | Environmental Variable | Description | Default | Example | | ----- | ----- | ----- | ----- | | HTTP_HOST | The protocol, hostname, and port (if necessary) for clients to connect to your server | | `http://192.168.0.2:8000` or `https://pinpoint.example.com` | -| MQTT_HOST | The hostname for clients to connect to the MQTT server. If set, client configuration links will use MQTT instead of HTTP settings. | | `pinpointmqtt.example.com` | +| MQTT_HOST | The hostname for clients to connect to the MQTT server. If not set, MQTT mode will be disabled. | | `pinpointmqtt.example.com` | | ADMIN_PASSWORD | The password for the admin account | `pinpointadmin` | `mysupersecretpassword` | | APPRISE_HOST | Either `cli` to use the [Apprise CLI](https://github.com/caronc/apprise) to send notifications or the protocol, hostname, and port of an [Apprise API](https://github.com/caronc/apprise-api) server | `cli` | `http://127.0.0.1:8000` | | APPRISE_EMAIL_URL | [Apprise URI](https://github.com/caronc/apprise/wiki) for sending emails to users. The user's email address will be appended to this to build the final URI. If not set, notifications will be disabled. | | `mailgun://admin@example.com/my-mailgun-token/` | diff --git a/bin/www b/bin/www index 281f819..8366bbc 100755 --- a/bin/www +++ b/bin/www @@ -3,13 +3,27 @@ require('dotenv').config(); const aedes = require('aedes')(); +const async = require('async'); const { CronJob } = require('cron'); const http = require('http'); const ws = require('websocket-stream'); const app = require('../src/app'); const { CardSeen } = require('../src/models/CardSeen'); +const { User } = require('../src/models/User'); +const { Device } = require('../src/models/Device'); const mqtt = require('../src/mqtt'); +// Ensure device links are up to date with current settings (in case they changed) +(async () => { + const allUsers = await User.getAll(); + await async.each(allUsers, async (user) => { + const allDevices = await user.getDevices(); + await async.each(allDevices, async (device) => { + await device.updateConfigLinks(user); + }); + }); +})(); + // Get port from environment and store in Express. const port = normalizePort(process.env.PORT || '8000'); diff --git a/src/models/Device.js b/src/models/Device.js index dffc094..ed938f9 100644 --- a/src/models/Device.js +++ b/src/models/Device.js @@ -7,7 +7,9 @@ const { Base } = require('./Base'); userId: "string", name: "string", initials: ["string"], - configLink: "string", + configLink: "string", <- old way + httpConfigLink: "string", + mqttConfigLink: "string", createdAt: Date, updatedAt: Date, } @@ -24,20 +26,24 @@ class Device extends Base { static async create(name, initials, card, user) { const existing = await Device.findOne({ userId: user._id, name }); if (existing) return null; - const configLink = getDeviceConfig(user, name, initials); + const mqttConfigLink = getMqttDeviceConfig(user, name, initials); + const httpConfigLink = getHttpDeviceConfig(user, name, initials); const device = new Device({ userId: user._id, name, initials, card, - configLink, + httpConfigLink, + mqttConfigLink }); await device.save(); return device; } async update(name, initials, card, user) { - this.configLink = getDeviceConfig(user, name, initials); + this.mqttConfigLink = getMqttDeviceConfig(user, name, initials); + this.httpConfigLink = getHttpDeviceConfig(user, name, initials); + if (this.configLink) this.configLink = null; this.name = name; this.initials = initials; this.card = card; @@ -45,6 +51,14 @@ class Device extends Base { return device; } + async updateConfigLinks(user) { + this.mqttConfigLink = getMqttDeviceConfig(user, this.name, this.initials); + this.httpConfigLink = getHttpDeviceConfig(user, this.name, this.initials); + if (this.configLink) this.configLink = null; + const device = await this.save(); + return device; + } + static async getByUserId(userId) { const devices = await Device.find({ userId }); return devices; @@ -55,7 +69,27 @@ function cleanString(str) { return str.replace(/[^A-Za-z0-9]/g, ''); } -function getDeviceConfig(userData, deviceName, initials) { +function getHttpDeviceConfig(userData, deviceName, initials) { + const httpConfig = { + _type: 'configuration', + autostartOnBoot: true, + deviceId: cleanString(deviceName), + locatorInterval: 300, + mode: 3, + monitoring: 1, + password: userData.passwordHash, + ping: 30, + pubExtendedData: true, + tid: initials, + tls: true, + url: `${process.env.HTTP_HOST}/pub`, + username: userData.username, + }; + const configBuffer = Buffer.from(JSON.stringify(httpConfig), 'utf8'); + return `owntracks:///config?inline=${configBuffer.toString('base64')}`; +} + +function getMqttDeviceConfig(userData, deviceName, initials) { const mqttConfig = { _type: 'configuration', autostartOnBoot: true, @@ -81,24 +115,7 @@ function getDeviceConfig(userData, deviceName, initials) { username: userData.username, ws: true, }; - - const httpConfig = { - _type: 'configuration', - autostartOnBoot: true, - deviceId: cleanString(deviceName), - locatorInterval: 300, - mode: 3, - monitoring: 1, - password: userData.passwordHash, - ping: 30, - pubExtendedData: true, - tid: initials, - tls: true, - url: `${process.env.HTTP_HOST}/pub`, - username: userData.username, - } - - const configBuffer = Buffer.from(JSON.stringify(process.env.MQTT_HOST ? mqttConfig : httpConfig), 'utf8'); + const configBuffer = Buffer.from(JSON.stringify(mqttConfig), 'utf8'); return `owntracks:///config?inline=${configBuffer.toString('base64')}`; } diff --git a/src/models/User.js b/src/models/User.js index 43f5368..8c009e6 100644 --- a/src/models/User.js +++ b/src/models/User.js @@ -104,7 +104,7 @@ class User extends Base { let sharers = []; groups.forEach((group) => { - const members = group.members.map((member) => member.userId); + const members = group.members.filter((member) => member.accepted).map((member) => member.userId); sharers = sharers.concat(members); }); @@ -114,6 +114,15 @@ class User extends Base { return [...new Set(sharers)]; } + async getFriendsAndGroupies() { + const groups = await this.getGroups(); + let friends = [...this.friends, this.username]; + + friends.concat(groups.flatMap((group) => group.members.filter((member) => member.accepted).map((member) => member.username))); + + return [...new Set(friends)]; + } + static async getByUsername(uname) { const user = await this.findOne({ username: uname }); return user; diff --git a/src/mqtt.js b/src/mqtt.js index 18b759b..946c648 100644 --- a/src/mqtt.js +++ b/src/mqtt.js @@ -3,6 +3,7 @@ const bcrypt = require('bcrypt'); const { User } = require('./models/User'); const { Device } = require('./models/Device'); const { CardSeen } = require('./models/CardSeen'); +const { Location } = require('./models/Location'); let aedes; const clientMap = {}; @@ -49,16 +50,24 @@ module.exports.authorizePublish = async (client, packet, callback) => { console.log(`${client.id} (${username}) published to ${packet.topic}: ${packet.payload}`); if (packet.topic.startsWith(`owntracks/${username}/`)) { callback(null); - await publishToFriends(client, packet); + await publishToPinpoint(client, packet); } else { callback(new Error('Invalid topic')); } }; -async function publishToFriends(client, packet) { +async function publishToPinpoint(client, packet) { const username = clientMap[client.id]; const user = await User.getByUsername(username); if (!user) return; + + const deviceRegex = new RegExp(`^owntracks/${username}/`); + const deviceName = packet.topic.replace(deviceRegex, ''); + const device = await Device.findOne({ userId: user._id, name: deviceName }); + if (!device) return; + + await Location.create(JSON.parse(packet.payload.toString()), user, device._id); + const { friends } = user; const groups = await user.getGroups(); await async.eachSeries(groups, async (group) => { @@ -70,14 +79,11 @@ async function publishToFriends(client, packet) { }); if (!friends.includes(username)) friends.push(username); await async.eachSeries(friends, async (friend) => { - console.log(`Forwarding packet to ${friend}`); const newPacket = { ...packet }; newPacket.topic = newPacket.topic.replace(/^owntracks/g, friend); + console.log(`Forwarding packet to ${newPacket.topic}`); aedes.publish(newPacket); // publish card if unseen - const deviceRegex = new RegExp(`^owntracks/${username}/`); - const deviceName = packet.topic.replace(deviceRegex, ''); - const device = await Device.findOne({ userId: user._id, name: deviceName }); if (device && device.card) { const friendData = await User.getByUsername(friend); const seen = await CardSeen.findOne({ deviceId: device._id, seerId: friendData._id }); diff --git a/src/routes/pub.js b/src/routes/pub.js index 675bdac..b7e2ddb 100644 --- a/src/routes/pub.js +++ b/src/routes/pub.js @@ -1,6 +1,7 @@ const async = require('async'); const express = require('express'); const userMw = require('../middleware/user'); +const mqtt = require('../mqtt'); const { Device } = require('../models/Device'); const { CardSeen } = require('../models/CardSeen'); const { Location } = require('../models/Location'); @@ -11,14 +12,31 @@ router.post('/', userMw.one, async (req, res) => { if (typeof req.body === 'object' && req.body?._type === 'location') { const deviceName = deviceNameFromTopic(req.user.username, req.body.topic); const userDevice = (await Device.getByUserId(req.User._id)).find((device) => device.name === deviceName); + if (!userDevice) { + console.log(`${req.user.username} posted a location update for an invalid device: ${deviceName}`); + return; + } console.log(`${req.user.username} posted a location update for device: ${deviceName}`); await Location.create(req.body, req.User, userDevice._id); const returnData = []; const returnUsernames = []; - const sharers = await req.User.getUsersSharingWith(); - if (!sharers.includes(req.User._id)) sharers.push(req.User._id); + + // Publish to friends MQTT topics + if (process.env.MQTT_HOST) { + const friends = await req.User.getFriendsAndGroupies(); + friends.forEach((friend) => { + console.log(`Publishing location to ${friend}/${req.User.username}/${deviceName}: ${JSON.stringify(req.body)}`); + try { + mqtt.publish({ cmd: 'publish', topic: `${friend}/${req.User.username}/${deviceName}`, payload: JSON.stringify(req.body) }, (err) => { if (err) console.error(err); }); + } catch (e) { + console.log(e); + } + }); + } // Loop through all people that share with the user who just posted their location + const sharers = await req.User.getUsersSharingWith(); + if (!sharers.includes(req.User._id)) sharers.push(req.User._id); await async.eachSeries(sharers, async (sharer) => { const lastLocs = await Location.getLastByUserId(sharer); // Get last location for all devices for the user console.log(`Sharer ${sharer} has ${lastLocs.length} location(s) to share`) @@ -42,8 +60,9 @@ router.post('/', userMw.one, async (req, res) => { console.log(`Returning ${returnUsernames.length} user location(s): ${returnUsernames.join(', ')}`); console.log(`Response size ${JSON.stringify(returnData).length} bytes`); return res.send(returnData); + } else { + res.send('Got it'); } - res.send('Got it'); }); function deviceNameFromTopic(username, topic) { diff --git a/src/routes/user.js b/src/routes/user.js index 60ebebf..8292d83 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -31,6 +31,10 @@ router.get('/dismiss-help', userMw.one, async (req, res) => { res.send(''); }); +router.get('/show-help', userMw.one, async (req, res) => { + res.render('user-help.html'); +}); + // !!!! devices !!!! router.get('/add-device', async (req, res) => { diff --git a/src/views/navbar.html b/src/views/navbar.html index 24ecba6..901f38a 100644 --- a/src/views/navbar.html +++ b/src/views/navbar.html @@ -28,6 +28,13 @@ {% endif %} + {% if userData and userData.helpDismissed %} + + {% endif %} {% endblock %} \ No newline at end of file diff --git a/src/views/user-devices.html b/src/views/user-devices.html index deedfa3..a30ec6c 100644 --- a/src/views/user-devices.html +++ b/src/views/user-devices.html @@ -2,7 +2,16 @@ {% for device in userDevices %} {{ device.name }} + {% if device.httpConfigLink %} + + OwnTracks HTTP Config + {% if settings.mqttEnabled and device.mqttConfigLink %} + OwnTracks MQTT Config + {% endif %} + + {% else %} OwnTracks Link + {% endif %} diff --git a/src/views/user-help.html b/src/views/user-help.html index 2c117aa..9371f3b 100644 --- a/src/views/user-help.html +++ b/src/views/user-help.html @@ -27,7 +27,7 @@
Welcome to Pinpoint!
  • -
    📲 Select the "OwnTracks Link" on a device with OwnTracks installed and you will be prompted to import the configuration.
    +
    📲 Select one of the "OwnTracks Config" links on a device with OwnTracks installed and you will be prompted to import the configuration. MQTT will give you instant updates when a friend updates their location, whereas HTTP polls periodically for updates and uses slightly less battery.
  • @@ -53,6 +53,6 @@
    Welcome to Pinpoint!
    - + \ No newline at end of file diff --git a/src/views/user.html b/src/views/user.html index 41a674e..d8a80e2 100644 --- a/src/views/user.html +++ b/src/views/user.html @@ -1,7 +1,9 @@ {% extends "navbar.html" %} {% block body %} -{% if not userData.helpDismissed %}{% include "user-help.html" %}{% endif %} +
    + {% if not userData.helpDismissed %}{% include "user-help.html" %}{% endif %} +

    Devices