diff --git a/lib/common.ts b/lib/common.ts index 54454c1..79d2e69 100644 --- a/lib/common.ts +++ b/lib/common.ts @@ -47,3 +47,17 @@ export function generateDeviceId( export function escapeRegExp(str: string): string { return str.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&"); } + +export function encodeTag(tag: string): string { + return encodeURIComponent(tag) + .replace( + /[!~*'()]/g, + (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase() + ) + .replace(/0x(?=[0-9A-Z]{2})/g, "0%78") + .replace(/%/g, "0x"); +} + +export function decodeTag(tag: string): string { + return decodeURIComponent(tag.replace(/0x(?=[0-9A-Z]{2})/g, "%")); +} diff --git a/lib/db.ts b/lib/db.ts index 06288ed..c15607b 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -19,7 +19,7 @@ import { MongoClient, ObjectID, Collection } from "mongodb"; import { get } from "./config"; -import { escapeRegExp } from "./common"; +import { encodeTag, escapeRegExp } from "./common"; import { parse } from "./common/expression-parser"; import { DeviceData, @@ -234,10 +234,9 @@ export async function fetchDevice( ]); } - for (let t of v as string[]) { - t = t.replace(/[^a-zA-Z0-9-]+/g, "_"); + for (const t of v as string[]) { res.push([ - Path.parse("Tags." + t), + Path.parse("Tags." + encodeTag(t)), timestamp, { object: [timestamp, 0], diff --git a/lib/mongodb-functions.ts b/lib/mongodb-functions.ts index 9a05223..f933ac0 100644 --- a/lib/mongodb-functions.ts +++ b/lib/mongodb-functions.ts @@ -21,6 +21,7 @@ import { ObjectID } from "mongodb"; import { map, parse, stringify, parseList } from "./common/expression-parser"; import { likePatternToRegExp } from "./common/expression"; import { Expression, Fault, Task } from "./types"; +import { decodeTag, encodeTag } from "./common"; const isArray = Array.isArray; @@ -43,7 +44,7 @@ export function processDeviceFilter(filter: Expression): Expression { exp[1][0] === "PARAM" && exp[1][1].startsWith("Tags.") ) { - const t = exp[1][1].slice(5); + const t = decodeTag(exp[1][1].slice(5)); if (exp[0] === "IS NULL") return ["_tags", t, false]; else if (exp[0] === "IS NOT NULL") return ["_tags", t, true]; else if (exp[0] === "=" && exp[2] === true) return ["_tags", t, true]; @@ -406,7 +407,7 @@ export function flattenDevice(device: Record): FlatDevice { }; for (const t of tree as string[]) { - output[`Tags.${t}`] = { + output[`Tags.${encodeTag(t)}`] = { value: [true, "xsd:boolean"], valueTimestamp: timestamp, writable: true, @@ -510,11 +511,11 @@ export function convertOldPrecondition( if (op === "$ne") { if (typeof v["$ne"] !== "string") throw new Error("Only string values are allowed for _tags"); - conjs.push(["IS NULL", ["PARAM", `Tags.${val}`]]); + conjs.push(["IS NULL", ["PARAM", `Tags.${encodeTag(val)}`]]); } else if (op === "$eq") { if (typeof v["$eq"] !== "string") throw new Error("Only string values are allowed for _tags"); - conjs.push(["IS NOT NULL", ["PARAM", `Tags.${val}`]]); + conjs.push(["IS NOT NULL", ["PARAM", `Tags.${encodeTag(val)}`]]); } else { throw new Error(`Invalid tag query`); } @@ -522,7 +523,10 @@ export function convertOldPrecondition( if (conjs.length === 1) expressions.push(conjs[0]); else if (conjs.length > 1) expressions.push(["AND", ...conjs]); } else { - expressions.push(["IS NOT NULL", ["PARAM", `Tags.${v}`]]); + expressions.push([ + "IS NOT NULL", + ["PARAM", `Tags.${encodeTag(v as string)}`], + ]); } } else if (k.startsWith("Tags.")) { let exists: boolean; diff --git a/lib/ui/api.ts b/lib/ui/api.ts index 42ff33d..16b0285 100644 --- a/lib/ui/api.ts +++ b/lib/ui/api.ts @@ -31,6 +31,7 @@ import { del } from "../cache"; import Authorizer from "../common/authorizer"; import { ping } from "../ping"; import * as url from "url"; +import { decodeTag } from "../common"; const router = new Router(); export default router; @@ -280,7 +281,7 @@ for (const [resource, flags] of Object.entries(resources)) { if (e[1] === "Tags") { const tags = []; for (const p in obj) - if (p.startsWith("Tags.")) tags.push(p.slice(5)); + if (p.startsWith("Tags.")) tags.push(decodeTag(p.slice(5))); return tags.join(", "); } diff --git a/lib/ui/db.ts b/lib/ui/db.ts index 3b76133..187e637 100644 --- a/lib/ui/db.ts +++ b/lib/ui/db.ts @@ -237,39 +237,27 @@ export function count(resource: string, filter: Expression): Promise { }); } -export function updateDeviceTags( +export async function updateDeviceTags( deviceId: string, tags: Record ): Promise { - return new Promise((resolve, reject) => { - const add = []; - const pull = []; + const add = []; + const pull = []; - const regex = /^[0-9a-zA-Z_]+$/; - for (let [tag, onOff] of Object.entries(tags)) { - tag = tag.trim(); - if (onOff) { - if (!tag.match(regex)) - return void reject(new Error(`Invalid tag '${tag}'`)); - add.push(tag); - } else { - pull.push(tag); - } - } - getClient() - .then((client) => { - const collection = client.db().collection("devices"); + for (let [tag, onOff] of Object.entries(tags)) { + tag = tag.trim(); + if (onOff) add.push(tag); + else pull.push(tag); + } - const object = {}; - if (add && add.length) object["$addToSet"] = { _tags: { $each: add } }; - if (pull && pull.length) object["$pullAll"] = { _tags: pull }; - collection.updateOne({ _id: deviceId }, object, (err) => { - if (err) return void reject(err); - resolve(); - }); - }) - .catch(reject); - }); + const client = await getClient(); + const collection = client.db().collection("devices"); + const object = {}; + + if (add && add.length) object["$addToSet"] = { _tags: { $each: add } }; + if (pull && pull.length) object["$pullAll"] = { _tags: pull }; + + await collection.updateOne({ _id: deviceId }, object); } function putResource(resource, id, object): Promise { diff --git a/ui/components/tags.ts b/ui/components/tags.ts index 35808f5..83d4659 100644 --- a/ui/components/tags.ts +++ b/ui/components/tags.ts @@ -22,6 +22,7 @@ import { m } from "../components"; import * as notifications from "../notifications"; import * as store from "../store"; import { getIcon } from "../icons"; +import { decodeTag } from "../../lib/common"; const component: ClosureComponent = (): Component => { return { @@ -32,7 +33,7 @@ const component: ClosureComponent = (): Component => { const tags = []; for (const p of Object.keys(device)) - if (p.startsWith("Tags.")) tags.push(p.slice(5)); + if (p.startsWith("Tags.")) tags.push(decodeTag(p.slice(5))); tags.sort(); diff --git a/ui/smart-query.ts b/ui/smart-query.ts index cc7e655..4976464 100644 --- a/ui/smart-query.ts +++ b/ui/smart-query.ts @@ -19,6 +19,7 @@ import config from "./config"; import { Expression } from "../lib/types"; +import { encodeTag } from "../lib/common"; const resources = { devices: {}, @@ -138,7 +139,7 @@ function queryMac(param, value): Expression { } function queryTag(tag: string): Expression { - const t = tag.replace(/[^a-zA-Z0-9-]+/g, "_"); + const t = encodeTag(tag); return ["IS NOT NULL", ["PARAM", `Tags.${t}`]]; }