Compare commits

...
15 Commits
17 changed files with 1128 additions and 221 deletions

No files matched your search

+18 -12
View File
@@ -145,15 +145,24 @@ export async function connectionRequest(
const debug = !!getConfig(snapshot, "cwmp.debug", {}, now, evalCallback);
let udpProm;
let udpProm = Promise.resolve(false);
if (udpConnectionRequestAddress) {
udpProm = udpConnectionRequest(
udpConnectionRequestAddress,
authExp,
UDP_CONNECTION_REQUEST_PORT,
debug,
deviceId
);
try {
const u = new URL("udp://" + udpConnectionRequestAddress);
udpProm = udpConnectionRequest(
u.hostname,
parseInt(u.port || "80"),
authExp,
UDP_CONNECTION_REQUEST_PORT,
debug,
deviceId
).then(
() => true,
() => false
);
} catch (err) {
// Ignore invalid address
}
}
const status = await httpConnectionRequest(
@@ -165,10 +174,7 @@ export async function connectionRequest(
deviceId
);
if (udpProm) {
await udpProm;
return "";
}
if (await udpProm) return "";
return status;
}
+1 -1
View File
@@ -51,7 +51,7 @@ export function escapeRegExp(str: string): string {
export function encodeTag(tag: string): string {
return encodeURIComponent(tag)
.replace(
/[!~*'()]/g,
/[!~*'().]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase()
)
.replace(/0x(?=[0-9A-Z]{2})/g, "0%78")
+104 -49
View File
@@ -23,6 +23,31 @@ type Alias = [Path, string][];
let cache1 = new Map<string, Path>();
let cache2 = new Map<string, Path>();
const CHAR_SPACE = 32;
const CHAR_BACKSLASH = 92;
const CHAR_COLON = 58;
const CHAR_QUOTE = 34;
const CHAR_OPEN_BRACKET = 91;
const CHAR_CLOSE_BRACKET = 93;
const CHAR_ASTERISK = 42;
const CHAR_COMMA = 44;
const CHAR_DOT = 46;
function charCodeAt(str: string, index: number): number {
if (index >= str.length) return 0;
return str.charCodeAt(index);
}
function legalChar(c: number): boolean {
return (
(c >= 97 && c <= 122) ||
(c >= 65 && c <= 90) ||
(c >= 48 && c <= 57) ||
c === 95 ||
c === 45
);
}
export default class Path {
public readonly segments: Segments;
public readonly wildcard: number;
@@ -30,37 +55,70 @@ export default class Path {
protected _string: string;
protected _stringIndex: number[];
protected static parseAliasValue(
pattern: string,
index: number
): { index: number; value: string } {
let i = index;
while (charCodeAt(pattern, i) === CHAR_SPACE) ++i;
if (charCodeAt(pattern, i) === CHAR_QUOTE) {
for (let j = i + 1; j < pattern.length; ++j) {
if (
pattern.charCodeAt(j) === CHAR_QUOTE &&
pattern.charCodeAt(j - 1) !== CHAR_BACKSLASH
) {
try {
++j;
const v = JSON.parse(pattern.slice(i, j));
return { index: j, value: v };
} catch {
return { index, value: null };
}
}
}
return { index, value: null };
}
for (; i < pattern.length; ++i) {
const c = pattern.charCodeAt(i);
if (c === CHAR_CLOSE_BRACKET || c === CHAR_COMMA) break;
}
return { index: i, value: pattern.slice(index, i).trim() };
}
protected static parseAliasPath(
pattern: string,
index: number
): { index: number; path: Path } {
let i = index;
while (charCodeAt(pattern, i) === CHAR_SPACE) ++i;
const { index: idx, segments } = Path.parsePath(pattern, i);
if (idx === i) return { index, path: null };
i = idx;
while (charCodeAt(pattern, i) === CHAR_SPACE) ++i;
if (charCodeAt(pattern, i) !== CHAR_COLON) return { index, path: null };
return { index: i + 1, path: new Path(segments) };
}
protected static parseAlias(
pattern: string,
index: number
): { index: number; alias: Alias } {
const alias: Alias = [];
while (index < pattern.length && pattern[index] !== "]") {
const { index: idx, segments } = Path.parsePath(pattern, index);
let j = (index = idx + 1);
while (pattern[j] !== "]" && pattern[j] !== ",") {
if (pattern[j] === '"' && index === j) {
++j;
while (pattern[j] !== '"' || pattern[j - 1] === "\\") {
if (++j >= pattern.length)
throw new Error("Invalid alias expression");
}
}
if (++j >= pattern.length) throw new Error("Invalid alias expression");
}
let i = index;
for (;;) {
let path: Path;
let value: string;
({ index: i, path } = Path.parseAliasPath(pattern, i));
if (!path) break;
({ index: i, value } = Path.parseAliasValue(pattern, i));
if (value == null) break;
alias.push([path, value]);
index = i;
let value = pattern.slice(index, j).trim();
index = j;
if (value[0] === '"') {
try {
value = JSON.parse(value);
} catch (error) {
throw new Error("Invalid alias expression");
}
}
alias.push([new Path(segments), value]);
if (pattern[index] === ",") ++index;
if (charCodeAt(pattern, index) !== CHAR_COMMA) break;
++i;
}
// Ensure identical expressions have idential string representation
@@ -81,30 +139,26 @@ export default class Path {
index: number
): { index: number; segments: Segments } {
const segments = [];
// Colon separator is needed for parseAlias
if (index < pattern.length && pattern[index] !== ":") {
for (;;) {
if (pattern[index] === "[") {
const { index: idx, alias } = Path.parseAlias(pattern, index + 1);
index = idx + 1;
segments.push(alias);
} else {
const j = index;
while (
index < pattern.length &&
pattern[index] !== ":" &&
pattern[index] !== "."
)
++index;
const s = pattern.slice(j, index).trim();
if (!s) throw new Error("Invalid parameter path");
segments.push(s);
}
if (index >= pattern.length || pattern[index] === ":") break;
else if (pattern[index] !== ".")
throw new Error("Invalid alias expression");
++index;
let segStart = index;
let wildcard = -1;
for (let i = index; i <= pattern.length; ++i) {
const c = charCodeAt(pattern, i);
if (c === CHAR_OPEN_BRACKET && i === segStart) {
const { index: idx, alias } = Path.parseAlias(pattern, i + 1);
if (charCodeAt(pattern, idx) !== CHAR_CLOSE_BRACKET) break;
segments.push(alias);
index = i = idx + 1;
segStart = i + 1;
} else if (c === CHAR_ASTERISK && i === segStart) {
wildcard = i + 1;
} else if (!legalChar(c) || i === wildcard) {
if (i === segStart) break;
const s = pattern.slice(segStart, i);
segments.push(s);
index = i;
if (c !== CHAR_DOT) break;
segStart = i + 1;
}
}
@@ -143,7 +197,8 @@ export default class Path {
if (!path) {
path = cache2.get(str);
if (!path) {
const { segments } = Path.parsePath(str, 0);
const { index, segments } = Path.parsePath(str, 0);
if (index < str.length) throw new Error("Invalid parameter path");
path = new Path(segments);
if (path.toString() !== str) cache1.set(path.toString(), path);
}
+11 -8
View File
@@ -169,9 +169,13 @@ export async function httpConnectionRequest(
if (res.statusCode === 503) return "Device is offline";
if (res.statusCode === 401 && res.headers["www-authenticate"]) {
authHeader = auth.parseWwwAuthenticateHeader(
res.headers["www-authenticate"]
);
try {
authHeader = auth.parseWwwAuthenticateHeader(
res.headers["www-authenticate"]
);
} catch (err) {
return "Connection request error: Error parsing www-authenticate header";
}
[username, password, authExp] = await extractAuth(authExp, false);
} else {
return `Connection request error: Unexpected status code ${res.statusCode}`;
@@ -182,14 +186,13 @@ export async function httpConnectionRequest(
}
export async function udpConnectionRequest(
address: string,
host: string,
port: number,
authExp: Expression,
sourcePort = 0,
_debug: boolean,
deviceId: string
): Promise<void> {
const [host, portStr] = address.split(":", 2);
const port = portStr ? parseInt(portStr) : 80;
const now = Date.now();
const client = dgram.createSocket({ type: "udp4", reuseAddr: true });
@@ -216,8 +219,8 @@ export async function udpConnectionRequest(
.createHmac("sha1", password)
.update(`${ts}${id}${username}${cn}`)
.digest("hex");
const uri = `http://${address}?ts=${ts}&id=${id}&un=${username}&cn=${cn}&sig=${sig}`;
const msg = `GET ${uri} HTTP/1.1\r\nHost: ${address}\r\n\r\n`;
const uri = `http://${host}:${port}?ts=${ts}&id=${id}&un=${username}&cn=${cn}&sig=${sig}`;
const msg = `GET ${uri} HTTP/1.1\r\nHost: ${host}:${port}\r\n\r\n`;
const message = Buffer.from(msg);
for (let i = 0; i < 3; ++i) {
+53 -6
View File
@@ -84,9 +84,13 @@ async function authenticate(
let authentication;
if (sessionContext.httpRequest.headers["authorization"]) {
authentication = auth.parseAuthorizationHeader(
sessionContext.httpRequest.headers["authorization"]
);
try {
authentication = auth.parseAuthorizationHeader(
sessionContext.httpRequest.headers["authorization"]
);
} catch (err) {
return false;
}
}
if (authentication && authentication.method === "Digest") {
@@ -208,7 +212,7 @@ async function writeResponse(
sessionContext.extendLock = now + 10000;
await cache.acquireLock(
`cwmp_session_${sessionContext.deviceId}`,
sessionContext.timeout + 15000,
sessionContext.timeout * 1000 + 15000,
0,
sessionContext.sessionId
);
@@ -357,6 +361,39 @@ async function transferComplete(sessionContext, rpc): Promise<void> {
return writeResponse(sessionContext, res);
}
async function duStateChangeComplete(sessionContext, rpc): Promise<void> {
const { acsResponse, operation, fault } = await session.duStateChangeComplete(
sessionContext,
rpc.cpeRequest
);
if (!operation) {
logger.accessWarn({
sessionContext: sessionContext,
message: "Unrecognized command key",
rpc: rpc,
});
}
if (fault) {
Object.assign(sessionContext.retries, operation.retries);
recordFault(
sessionContext,
fault,
operation.provisions,
operation.channels
);
}
const res = soap.response({
id: rpc.id,
acsResponse: acsResponse,
cwmpVersion: sessionContext.cwmpVersion,
});
return writeResponse(sessionContext, res);
}
// Append provisions and remove duplicates
function appendProvisions(original, toAppend): boolean {
let modified = false;
@@ -891,7 +928,7 @@ async function getSession(
const sessionContextString = await cache.pop(`session_${sessionId}`);
if (!sessionContextString) return null;
sessionContext = await session.deserialize(sessionContextString);
connection.setTimeout(sessionContext.timeout);
connection.setTimeout(sessionContext.timeout * 1000);
return sessionContext;
}
@@ -1146,7 +1183,7 @@ async function processRequest(
sessionContext.extendLock = sessionContext.timestamp + 10000;
await cache.acquireLock(
`cwmp_session_${sessionContext.deviceId}`,
sessionContext.timeout + 15000,
sessionContext.timeout * 1000 + 15000,
0,
sessionContext.sessionId
);
@@ -1235,6 +1272,16 @@ async function processRequest(
cwmpVersion: sessionContext.cwmpVersion,
});
return writeResponse(sessionContext, res);
} else if (rpc.cpeRequest.name === "DUStateChangeComplete") {
if (sessionContext.state !== 1) return reportBadState(sessionContext);
logger.accessInfo({
sessionContext: sessionContext,
message: "CPE request",
rpc: rpc,
});
return duStateChangeComplete(sessionContext, rpc);
} else {
if (sessionContext.state !== 1 || rpc.cpeRequest.name === "Inform")
return void reportBadState(sessionContext);
+26 -3
View File
@@ -19,7 +19,7 @@
import { MongoClient, ObjectID, Collection, Db } from "mongodb";
import { get } from "./config";
import { encodeTag, escapeRegExp } from "./common";
import { decodeTag, encodeTag, escapeRegExp } from "./common";
import { parse } from "./common/expression-parser";
import {
DeviceData,
@@ -46,6 +46,8 @@ export let tasksCollection: Collection,
let clientPromise: Promise<MongoClient>;
const INVALID_PATH_SUFFIX = "__invalid";
function compareAccessLists(list1: string[], list2: string[]): boolean {
if (list1.length !== list2.length) return false;
for (const [i, v] of list1.entries()) if (v !== list2[i]) return false;
@@ -167,7 +169,19 @@ export async function fetchDevice(
if (obj["_accessList"] != null)
attrs.accessList = [obj["_attributesTimestamp"] || 1, obj["_accessList"]];
res.push([Path.parse(path), t, attrs]);
try {
res.push([Path.parse(path), t, attrs]);
} catch (err) {
// The path parser is now more strict so we might be in a situation where
// the database contains invalid paths from before this change So here we
// encode the invalid characters.
const splits = path.split(".");
splits[splits.length - 1] =
encodeTag(splits[splits.length - 1]) + INVALID_PATH_SUFFIX;
path = splits.join(".");
res.push([Path.parse(path), t, attrs]);
return;
}
for (const [k, v] of Object.entries(obj)) {
if (!k.startsWith("_")) {
@@ -449,7 +463,16 @@ export async function saveDevice(
break;
default:
if (!diff[2]) {
update["$unset"][path.toString()] = 1;
let pathStr = path.toString();
// Paths with that suffix are encoded and need to be decoded
if (pathStr.endsWith(INVALID_PATH_SUFFIX)) {
const splits = pathStr.split(".");
splits[splits.length - 1] = decodeTag(
splits[splits.length - 1].slice(0, 0 - INVALID_PATH_SUFFIX.length)
);
pathStr = splits.join(".");
}
update["$unset"][pathStr] = 1;
continue;
}
+10 -9
View File
@@ -30,14 +30,6 @@ const TIMEOUT = +config.get("EXT_TIMEOUT");
const processes: { [script: string]: ChildProcess } = {};
const jobs = new Map();
function messageHandler(message): void {
const func = jobs.get(message[0]);
if (func) {
jobs.delete(message[0]);
func({ fault: message[1], value: message[2] });
}
}
export function run(args: string[]): Promise<{ fault: Fault; value: any }> {
return new Promise((resolve) => {
const scriptName = args[0];
@@ -70,7 +62,16 @@ export function run(args: string[]): Promise<{ fault: Fault; value: any }> {
if (processes[scriptName] === p) delete processes[scriptName];
});
p.on("message", messageHandler);
p.on("message", (message) => {
const func = jobs.get(message[0]);
if (func) {
jobs.delete(message[0]);
// Wait for any disconnect even to fire
setTimeout(() => {
func({ fault: message[1], value: message[2] });
});
}
});
const rlstdout = readline.createInterface(p.stdout);
rlstdout.on("line", (line) => {
+512 -27
View File
@@ -57,8 +57,13 @@ import {
FactoryReset,
AddObjectResponse,
GetParameterValuesResponse,
ChangeDUStateOperation,
DownloadOperation,
DUStateChangeCompleteRequest,
ChangeDUState,
} from "./types";
import { getRequestOrigin } from "./forwarded";
import * as logger from "./logger";
const VALID_PARAM_TYPES = new Set([
"xsd:int",
@@ -283,6 +288,8 @@ export async function transferComplete(
sessionContext.deviceData.attributes.revision = revision;
const commandKey = rpcReq.commandKey;
const operation = sessionContext.operations[commandKey];
if (operation.name !== "Download")
throw new Error("Unexpected operation name");
if (!operation) {
return {
@@ -432,6 +439,142 @@ function revertDownloadParameters(
}
}
export async function duStateChangeComplete(
sessionContext: SessionContext,
rpcReq: DUStateChangeCompleteRequest
): Promise<{
acsResponse: AcsResponse;
operation: ChangeDUStateOperation;
fault: Fault;
}> {
const revision =
(sessionContext.revisions[sessionContext.revisions.length - 1] || 0) + 1;
sessionContext.deviceData.timestamps.revision = revision;
sessionContext.deviceData.attributes.revision = revision;
const commandKey = rpcReq.commandKey;
const operation = sessionContext.operations[commandKey];
if (!operation) {
return {
acsResponse: { name: "DUStateChangeCompleteResponse" },
operation: null,
fault: null,
};
}
if (operation.name !== "ChangeDUState")
throw new Error("Unexpected operation name");
if (operation.args.operations.length !== rpcReq.results.length) {
return {
acsResponse: { name: "DUStateChangeCompleteResponse" },
operation: operation,
fault: {
code: "invalid_response",
message: "Unexpected number of results in DUStateChangeComplete",
timestamp: operation.timestamp,
},
};
}
delete sessionContext.operations[commandKey];
if (!sessionContext.operationsTouched) sessionContext.operationsTouched = {};
sessionContext.operationsTouched[commandKey] = 1;
let toClear = null;
const timestamp = sessionContext.timestamp + sessionContext.iteration + 1;
let fault: Fault;
for (const [i, res] of rpcReq.results.entries()) {
const op = operation.args.operations[i];
if (res.fault && res.fault.faultCode !== "0") {
revertChangeDuStateParameters(sessionContext, op.instance);
fault = {
code: `cwmp.${res.fault.faultCode}`,
message: res.fault.faultString,
timestamp: operation.timestamp,
detail: res.fault,
};
continue;
}
const toSet: Record<string, [string | number | boolean, string]> = {
LastChangeDUState: [operation.timestamp, "xsd:dateTime"],
LastOperationType: [op.operationType, "xsd:string"],
LastURL: [op.url, "xsd:string"],
LastUUID: [op.uuid, "xsd:string"],
LastExecutionEnvRef: [op.executionEnvRef, "xsd:string"],
StartTime: [+res.startTime, "xsd:dateTime"],
CompleteTime: [+res.completeTime, "xsd:dateTime"],
CurrentState: [res.currentState, "xsd:string"],
DeploymentUnitRef: [res.deploymentUnitRef, "xsd:string"],
Version: [res.version, "xsd:string"],
Resolved: [res.resolved, "xsd:string"],
ExecutionUnitRefList: [res.executionUnitRefList, "xsd:string"],
};
for (const [k, v] of Object.entries(toSet)) {
const p = sessionContext.deviceData.paths.add(
Path.parse(`DUStates.${op.instance}.${k}`)
);
toClear = device.set(
sessionContext.deviceData,
p,
timestamp,
{ value: [timestamp, v] },
toClear
);
}
}
if (toClear) {
for (const c of toClear)
device.clear(sessionContext.deviceData, c[0], c[1], c[2], c[3]);
}
return {
acsResponse: { name: "DUStateChangeCompleteResponse" },
operation: operation,
fault: fault,
};
}
function revertChangeDuStateParameters(
sessionContext: SessionContext,
instance
): void {
const timestamp = sessionContext.timestamp + sessionContext.iteration + 1;
let p;
p = sessionContext.deviceData.paths.add(
Path.parse(`DUStates.${instance}.LastChangeDUState`)
);
const lastChangeDuState = sessionContext.deviceData.attributes.get(p);
p = sessionContext.deviceData.paths.add(
Path.parse(`DUStates.${instance}.ChangeDUState`)
);
const toClear = device.set(sessionContext.deviceData, p, timestamp, {
value: [
timestamp,
[
lastChangeDuState && lastChangeDuState.value[1]
? lastChangeDuState.value[1][0]
: 0,
"xsd:dateTime",
],
],
});
if (toClear) {
for (const c of toClear)
device.clear(sessionContext.deviceData, c[0], c[1], c[2], c[3]);
}
}
export async function timeoutOperations(
sessionContext: SessionContext
): Promise<{ faults: Fault[]; operations: Operation[] }> {
@@ -793,6 +936,10 @@ function runDeclarations(
downloadsToCreate: new InstanceSet(),
downloadsValues: new Map(),
downloadsDownload: new Map(),
duStatesToDelete: new Set(),
duStatesToCreate: new InstanceSet(),
duStatesValues: new Map(),
duStatesChangeDUState: new Map(),
reboot: 0,
factoryReset: 0,
};
@@ -1438,6 +1585,147 @@ export async function rpcRequest(
}
}
// DUState
index = null;
for (const instance of sessionContext.syncState.duStatesToCreate) {
if (index == null) {
index = 0;
for (const p of sessionContext.deviceData.paths.find(
Path.parse("DUStates.*"),
false,
true
)) {
if (
+p.segments[1] > index &&
sessionContext.deviceData.attributes.has(p)
)
index = +p.segments[1];
}
}
++index;
toClear = device.set(
sessionContext.deviceData,
Path.parse("DUStates"),
timestamp,
{ object: [timestamp, 1], writable: [timestamp, 1] },
toClear
);
toClear = device.set(
sessionContext.deviceData,
Path.parse(`DUStates.${index}`),
timestamp,
{ object: [timestamp, 1], writable: [timestamp, 1] },
toClear
);
const params = {
URL: {
writable: 1,
value: [instance.URL || "", "xsd:string"],
},
UUID: {
writable: 1,
value: [instance.UUID || "", "xsd:string"],
},
ExecutionEnvRef: {
writable: 1,
value: [instance.ExecutionEnvRef || "", "xsd:string"],
},
Username: {
writable: 1,
value: [instance.Username || "", "xsd:string"],
},
Password: {
writable: 1,
value: [instance.Password || "", "xsd:string"],
},
ChangeDUState: {
writable: 1,
value: [instance.ChangeDUState || "", "xsd:dateTime"],
},
OperationType: {
writable: 1,
value: [instance.OperationType || "", "xsd:string"],
},
LastURL: { writable: 0, value: ["", "xsd:string"] },
LastUUID: { writable: 0, value: ["", "xsd:string"] },
LastExecutionEnvRef: { writable: 0, value: ["", "xsd:string"] },
LastChangeDUState: { writable: 0, value: [0, "xsd:dateTime"] },
LastOperationType: { writable: 0, value: [0, "xsd:string"] },
Version: { writable: 0, value: [0, "xsd:string"] },
CurrentState: { writable: 0, value: [0, "xsd:string"] },
ExecutionUnitRefList: { writable: 0, value: [0, "xsd:string"] },
DeploymentUnitRef: { writable: 0, value: [0, "xsd:string"] },
Resolved: { writable: 0, value: [0, "xsd:boolean"] },
StartTime: { writable: 0, value: [0, "xsd:dateTime"] },
CompleteTime: { writable: 0, value: [0, "xsd:dateTime"] },
};
for (const [k, v] of Object.entries(params)) {
toClear = device.set(
sessionContext.deviceData,
Path.parse(`DUStates.${index}.${k}`),
timestamp,
{
object: [timestamp, 0],
writable: [timestamp, v.writable as 0 | 1],
value: [
timestamp,
v.value as [string | number | boolean, string],
],
},
toClear
);
}
toClear = device.set(
sessionContext.deviceData,
Path.parse(`DUStates.${index}.*`),
timestamp,
null,
toClear
);
}
sessionContext.syncState.duStatesToCreate.clear();
for (const instance of sessionContext.syncState.duStatesToDelete) {
toClear = device.set(
sessionContext.deviceData,
instance,
timestamp,
null,
toClear
);
for (const p of sessionContext.syncState.duStatesValues.keys()) {
if (p.segments[1] === instance.segments[1])
sessionContext.syncState.duStatesValues.delete(p);
}
}
sessionContext.syncState.duStatesToDelete.clear();
for (const [p, v] of sessionContext.syncState.duStatesValues) {
const attrs = sessionContext.deviceData.attributes.get(p);
if (attrs) {
if (attrs.writable && attrs.writable[1] && attrs.value) {
const val = device.sanitizeParameterValue([v, attrs.value[1][1]]);
if (val[0] !== attrs.value[1][0]) {
toClear = device.set(
sessionContext.deviceData,
p,
timestamp,
{ value: [timestamp, val] },
toClear
);
}
}
}
}
if (toClear || sessionContext.deviceData.changes.has("prerequisite")) {
if (toClear) {
for (const c of toClear)
@@ -1740,6 +2028,7 @@ function generateSetRpcRequest(
| FactoryReset
| Reboot
| Download
| ChangeDUState
) & { next?: string } {
const syncState = sessionContext.syncState;
if (!syncState) return null;
@@ -1918,6 +2207,76 @@ function generateSetRpcRequest(
}
}
// ChangeDUState
const duStateOps: ChangeDUState["operations"] = [];
for (const [p, t] of syncState.duStatesChangeDUState) {
const attrs = deviceData.attributes.get(p);
if (attrs && attrs.value && t <= attrs.value[1][0]) continue;
const urlAttrs = deviceData.attributes.get(
deviceData.paths.get(p.slice(0, -1).concat(Path.parse("URL")))
);
const operationTypeAttrs = deviceData.attributes.get(
deviceData.paths.get(p.slice(0, -1).concat(Path.parse("OperationType")))
);
const uuidAttrs = deviceData.attributes.get(
deviceData.paths.get(p.slice(0, -1).concat(Path.parse("UUID")))
);
const executionEnvRefAttrs = deviceData.attributes.get(
deviceData.paths.get(p.slice(0, -1).concat(Path.parse("ExecutionEnvRef")))
);
const versionAttrs = deviceData.attributes.get(
deviceData.paths.get(p.slice(0, -1).concat(Path.parse("Version")))
);
const usernameAttrs = deviceData.attributes.get(
deviceData.paths.get(p.slice(0, -1).concat(Path.parse("Username")))
);
const passwordAttrs = deviceData.attributes.get(
deviceData.paths.get(p.slice(0, -1).concat(Path.parse("Password")))
);
const operationType = operationTypeAttrs
? (operationTypeAttrs.value[1][0] as string)
: null;
const url = urlAttrs ? (urlAttrs.value[1][0] as string) : null;
const uuid = uuidAttrs ? (uuidAttrs.value[1][0] as string) : null;
const executionEnvRef = executionEnvRefAttrs
? (executionEnvRefAttrs.value[1][0] as string)
: null;
const version = versionAttrs ? (versionAttrs.value[1][0] as string) : null;
const username = usernameAttrs
? (usernameAttrs.value[1][0] as string)
: null;
const password = passwordAttrs
? (passwordAttrs.value[1][0] as string)
: null;
if (
operationType !== "install" &&
operationType !== "update" &&
operationType !== "uninstall"
)
continue;
duStateOps.push({
operationType: operationType,
instance: p.segments[1] as string,
url: url,
uuid: uuid,
executionEnvRef: executionEnvRef,
version: version,
username: username,
password: password,
});
}
if (duStateOps.length) {
return {
name: "ChangeDUState",
commandKey: generateRpcId(sessionContext),
operations: duStateOps,
};
}
// Reboot
if (syncState.reboot) {
const p = sessionContext.deviceData.paths.get(Path.parse("Reboot"));
@@ -2199,6 +2558,26 @@ function processDeclarations(
}
}
break;
case "DUStates":
if (
currentPath.length === 3 &&
currentPath.wildcard === 0 &&
declareAttributeValues &&
declareAttributeValues.value
) {
if (currentPath.segments[2] === "ChangeDUState") {
syncState.duStatesChangeDUState.set(
currentPath,
declareAttributeValues.value[0]
);
} else {
syncState.duStatesValues.set(
currentPath,
declareAttributeValues.value[0]
);
}
}
break;
case "VirtualParameters":
if (currentPath.length <= 2) {
let d;
@@ -2290,7 +2669,7 @@ function processDeclarations(
syncState.refreshAttributes.object.add(currentPath);
else if (currentAttributes.object[1] === 0)
syncState.refreshAttributes.value.add(currentPath);
} else {
} else if (attrName in syncState.refreshAttributes) {
syncState.refreshAttributes[attrName].add(currentPath);
}
}
@@ -2369,6 +2748,10 @@ function processInstances(
if (parent.length !== 1) return;
instancesToDelete = sessionContext.syncState.downloadsToDelete;
instancesToCreate = sessionContext.syncState.downloadsToCreate;
} else if (parent.segments[0] === "DUStates") {
if (parent.length !== 1) return;
instancesToDelete = sessionContext.syncState.duStatesToDelete;
instancesToCreate = sessionContext.syncState.duStatesToCreate;
} else {
instancesToDelete = sessionContext.syncState.instancesToDelete.get(parent);
if (instancesToDelete == null) {
@@ -2420,8 +2803,15 @@ export async function rpcResponse(
id: string,
_rpcRes: CpeResponse
): Promise<Fault> {
function invalidResponse(message: string): Fault {
return {
code: "invalid_response",
message: message,
};
}
if (id !== generateRpcId(sessionContext))
throw new Error("Request ID not recognized");
return invalidResponse("Request ID not recognized");
++sessionContext.rpcCount;
@@ -2512,47 +2902,99 @@ export async function rpcResponse(
if (rpcRes.name === "GetParameterValuesResponse") {
if (rpcReq.name !== "GetParameterValues")
throw new Error("Response name does not match request name");
return invalidResponse("Response name does not match request name");
if (rpcRes.parameterList.length !== rpcReq.parameterNames.length) {
return {
code: `invalid_response`,
message:
"The list of parameters returned in the GetParameterValuesResponse message does not match the requested parameters",
};
}
const requested = new Set(rpcReq.parameterNames);
for (const p of rpcRes.parameterList) {
for (const [path, value, type] of rpcRes.parameterList) {
if (!requested.delete(path.toString())) {
logger.accessWarn({
sessionContext: sessionContext,
message: "Unexpected parameter in response",
parameter: path.toString(),
});
continue;
}
toClear = device.set(
sessionContext.deviceData,
p[0],
path,
timestamp,
{
object: [timestamp, 0],
value: [timestamp, p.slice(1) as [string | number | boolean, string]],
value: [timestamp, [value, type]],
},
toClear
);
}
if (requested.size) {
for (const p of requested) {
logger.accessWarn({
sessionContext: sessionContext,
message: "Missing parameter in response",
parameter: p,
});
toClear = device.set(
sessionContext.deviceData,
Path.parse(p),
timestamp,
{
object: [timestamp, 0],
value: [timestamp, ["", "xsd:string"]],
},
toClear
);
}
}
} else if (rpcRes.name === "GetParameterAttributesResponse") {
if (rpcReq.name !== "GetParameterAttributes")
throw new Error("Response name does not match request name");
for (const p of rpcRes.parameterList) {
const requested = new Set(rpcReq.parameterNames);
for (const [path, notification, accessList] of rpcRes.parameterList) {
if (!requested.delete(path.toString())) {
logger.accessWarn({
sessionContext: sessionContext,
message: "Unexpected parameter in response",
parameter: path.toString(),
});
continue;
}
toClear = device.set(
sessionContext.deviceData,
p[0],
path,
timestamp,
{
notification: [timestamp, p[1] as number],
accessList: [timestamp, p[2] as string[]],
notification: [timestamp, notification],
accessList: [timestamp, accessList],
},
toClear
);
}
if (requested.size) {
for (const p of requested) {
logger.accessWarn({
sessionContext: sessionContext,
message: "Missing parameter in response",
parameter: p,
});
toClear = device.set(
sessionContext.deviceData,
Path.parse(p),
timestamp,
{
notification: [timestamp, 0],
accessList: [timestamp, []],
},
toClear
);
}
}
} else if (rpcRes.name === "GetParameterNamesResponse") {
if (rpcReq.name !== "GetParameterNames")
throw new Error("Response name does not match request name");
return invalidResponse("Response name does not match request name");
let root: Path;
if (rpcReq.parameterPath.endsWith("."))
@@ -2598,6 +3040,7 @@ export async function rpcResponse(
"FactoryReset",
"VirtualParameters",
"Downloads",
"DUStates",
]) {
const p = sessionContext.deviceData.paths.get(Path.parse(n));
if (p && sessionContext.deviceData.attributes.has(p))
@@ -2609,6 +3052,14 @@ export async function rpcResponse(
const wildcardParams: Path[] = [root.concat(wildcardPath)];
for (const [path, object, writable] of rpcRes.parameterList) {
if (!path.toString().startsWith(rpcReq.parameterPath)) {
logger.accessWarn({
sessionContext: sessionContext,
message: "Unexpected parameter in response",
parameter: path.toString(),
});
continue;
}
if (object && !rpcReq.nextLevel)
wildcardParams.push(path.concat(wildcardPath));
@@ -2635,7 +3086,7 @@ export async function rpcResponse(
}
} else if (rpcRes.name === "SetParameterValuesResponse") {
if (rpcReq.name !== "SetParameterValues")
throw new Error("Response name does not match request name");
return invalidResponse("Response name does not match request name");
for (const p of rpcReq.parameterList) {
toClear = device.set(
@@ -2654,7 +3105,7 @@ export async function rpcResponse(
}
} else if (rpcRes.name === "SetParameterAttributesResponse") {
if (rpcReq.name !== "SetParameterAttributes")
throw new Error("Response name does not match request name");
return invalidResponse("Response name does not match request name");
for (const p of rpcReq.parameterList) {
let attrs;
@@ -2684,7 +3135,7 @@ export async function rpcResponse(
}
} else if (rpcRes.name === "AddObjectResponse") {
if (rpcReq.name !== "AddObject")
throw new Error("Response name does not match request name");
return invalidResponse("Response name does not match request name");
toClear = device.set(
sessionContext.deviceData,
@@ -2695,7 +3146,7 @@ export async function rpcResponse(
);
} else if (rpcRes.name === "DeleteObjectResponse") {
if (rpcReq.name !== "DeleteObject")
throw new Error("Response name does not match request name");
return invalidResponse("Response name does not match request name");
toClear = device.set(
sessionContext.deviceData,
@@ -2706,7 +3157,7 @@ export async function rpcResponse(
);
} else if (rpcRes.name === "RebootResponse") {
if (rpcReq.name !== "Reboot")
throw new Error("Response name does not match request name");
return invalidResponse("Response name does not match request name");
toClear = device.set(
sessionContext.deviceData,
@@ -2717,7 +3168,7 @@ export async function rpcResponse(
);
} else if (rpcRes.name === "FactoryResetResponse") {
if (rpcReq.name !== "FactoryReset")
throw new Error("Response name does not match request name");
return invalidResponse("Response name does not match request name");
toClear = device.set(
sessionContext.deviceData,
@@ -2728,7 +3179,7 @@ export async function rpcResponse(
);
} else if (rpcRes.name === "DownloadResponse") {
if (rpcReq.name !== "Download")
throw new Error("Response name does not match request name");
return invalidResponse("Response name does not match request name");
toClear = device.set(
sessionContext.deviceData,
@@ -2789,7 +3240,7 @@ export async function rpcResponse(
toClear
);
} else {
const operation = {
const operation: DownloadOperation = {
name: "Download",
timestamp: sessionContext.timestamp,
provisions: sessionContext.provisions,
@@ -2813,8 +3264,42 @@ export async function rpcResponse(
sessionContext.operationsTouched = {};
sessionContext.operationsTouched[rpcReq.commandKey] = 1;
}
} else if (rpcRes.name === "ChangeDUStateResponse") {
if (rpcReq.name !== "ChangeDUState")
return invalidResponse("Response name does not match request name");
for (const op of rpcReq.operations) {
toClear = device.set(
sessionContext.deviceData,
Path.parse(`DUStates.${op.instance}.ChangeDUState`),
timestamp + 1,
{ value: [timestamp + 1, [sessionContext.timestamp, "xsd:dateTime"]] },
toClear
);
}
const operation = {
name: "ChangeDUState",
timestamp: sessionContext.timestamp,
provisions: sessionContext.provisions,
channels: sessionContext.channels,
retries: {},
args: {
operations: rpcReq.operations,
},
};
for (const channel of Object.keys(sessionContext.channels)) {
if (sessionContext.retries[channel] != null)
operation.retries[channel] = sessionContext.retries[channel];
}
sessionContext.operations[rpcReq.commandKey] = operation as Operation;
if (!sessionContext.operationsTouched)
sessionContext.operationsTouched = {};
sessionContext.operationsTouched[rpcReq.commandKey] = 1;
} else {
throw new Error("Response name not recognized");
return invalidResponse("Response name not recognized");
}
if (toClear) {
+170 -12
View File
@@ -48,6 +48,9 @@ import {
GetRPCMethodsRequest,
RequestDownloadRequest,
AcsResponse,
ChangeDUState,
ChangeDUStateResponse,
DUStateChangeCompleteRequest,
} from "./types";
import Path from "./common/path";
@@ -109,8 +112,8 @@ function event(xml: Element): string[] {
function parameterInfoList(xml: Element): [Path, boolean, boolean][] {
return xml.children
.filter((e) => e.localName === "ParameterInfoStruct")
.map<[Path, boolean, boolean]>((e) => {
if (e.localName !== "ParameterInfoStruct") return null;
let param: string, value: string;
for (const c of e.children) {
switch (c.localName) {
@@ -134,10 +137,20 @@ function parameterInfoList(xml: Element): [Path, boolean, boolean][] {
parsed = false;
}
if (param && !param.endsWith("."))
return [Path.parse(param), false, parsed];
else return [Path.parse(param.slice(0, -1)), true, parsed];
});
try {
if (param && !param.endsWith("."))
return [Path.parse(param), false, parsed];
else return [Path.parse(param.slice(0, -1)), true, parsed];
} catch (err) {
warnings.push({
message: "Missing or invalid XML node",
element: "Name",
parameter: param,
});
return null;
}
})
.filter((e) => e != null);
}
const getValueType = memoize((str: string) => {
@@ -151,8 +164,8 @@ function parameterValueList(
xml: Element
): [Path, string | number | boolean, string][] {
return xml.children
.filter((e) => e.localName === "ParameterValueStruct")
.map<[Path, string | number | boolean, string]>((e) => {
if (e.localName !== "ParameterValueStruct") return null;
let valueElement: Element, param: string;
for (const c of e.children) {
switch (c.localName) {
@@ -208,15 +221,24 @@ function parameterValueList(
parsed = value;
}
}
return [Path.parse(param), parsed, valueType];
});
try {
return [Path.parse(param), parsed, valueType];
} catch (err) {
warnings.push({
message: "Missing or invalid XML node",
element: "Name",
parameter: param,
});
return null;
}
})
.filter((e) => e != null);
}
function parameterAttributeList(xml: Element): [Path, number, string[]][] {
return xml.children
.filter((e) => e.localName === "ParameterAttributeStruct")
.map<[Path, number, string[]]>((e) => {
if (e.localName !== "ParameterAttributeStruct") return null;
let notificationElement: Element,
accessListElement: Element,
param: string;
@@ -248,8 +270,18 @@ function parameterAttributeList(xml: Element): [Path, number, string[]][] {
.filter((c) => c.localName === "string")
.map((c) => decodeEntities(c.text));
return [Path.parse(param), notification, accessList];
});
try {
return [Path.parse(param), notification, accessList];
} catch (err) {
warnings.push({
message: "Missing or invalid XML node",
element: "Name",
parameter: param,
});
return null;
}
})
.filter((e) => e != null);
}
function GetParameterNames(methodRequest): string {
@@ -544,6 +576,29 @@ function DownloadResponse(xml: Element): DownloadResponse {
};
}
function ChangeDUState(methodRequest: ChangeDUState): string {
const operations = methodRequest.operations.map((op) => {
if (op.operationType === "install")
return `<Operations xsi:type="cwmp:InstallOpStruct"><UUID>${op.uuid}</UUID><URL>${op.url}</URL><ExecutionEnvRef>${op.executionEnvRef}</ExecutionEnvRef><Username>${op.username}</Username><Password>${op.password}</Password></Operations>`;
else if (op.operationType === "update")
return `<Operations xsi:type="cwmp:UpdateOpStruct"><UUID>${op.uuid}</UUID><URL>${op.url}</URL><Version>${op.version}</Version><Username>${op.username}</Username><Password>${op.password}</Password></Operations>`;
else if (op.operationType === "uninstall")
return `<Operations xsi:type="cwmp:UninstallOpStruct"><UUID>${op.uuid}</UUID><Version>${op.version}</Version><ExecutionEnvRef>${op.executionEnvRef}</ExecutionEnvRef></Operations>`;
return "";
});
return `<cwmp:ChangeDUState><CommandKey>${
methodRequest.commandKey || ""
}</CommandKey>${operations.join("")}</cwmp:ChangeDUState>`;
}
function ChangeDUStateResponse(): ChangeDUStateResponse {
return {
name: "ChangeDUStateResponse",
};
}
function Inform(xml: Element): InformRequest {
let retryCount: number, evnt: string[];
let parameterList: [Path, string | number | boolean, string][];
@@ -690,6 +745,97 @@ function TransferCompleteResponse(): string {
return "<cwmp:TransferCompleteResponse></cwmp:TransferCompleteResponse>";
}
function DUStateChangeComplete(xml: Element): DUStateChangeCompleteRequest {
let commandKey: string,
results: DUStateChangeCompleteRequest["results"],
res: DUStateChangeCompleteRequest["results"][0];
for (const c of xml.children) {
switch (c.localName) {
case "CommandKey":
commandKey = c.text;
break;
case "Results":
if (!results) results = [];
res = {
uuid: null,
deploymentUnitRef: null,
version: null,
currentState: null,
resolved: true,
executionUnitRefList: null,
startTime: null,
completeTime: null,
fault: null,
};
for (const cc of c.children) {
switch (cc.localName) {
case "UUID":
res.uuid = cc.text;
break;
case "DeploymentUnitRef":
res.deploymentUnitRef = cc.text;
break;
case "Version":
res.version = cc.text;
break;
case "CurrentState":
res.currentState = cc.text;
break;
case "Resolved":
res.resolved = parseBool(cc.text);
break;
case "ExecutionUnitRefList":
res.executionUnitRefList = cc.text;
break;
case "StartTime":
if (c.text) res.startTime = Date.parse(c.text);
break;
case "CompleteTime":
if (c.text) res.completeTime = Date.parse(c.text);
break;
case "Fault":
res.fault = {
faultCode: cc.children.find((n) => n.localName === "FaultCode")
.text,
faultString: cc.children.find(
(n) => n.localName === "FaultString"
).text,
};
break;
}
}
results.push(res);
break;
}
}
if (commandKey == null) {
warnings.push({
message: "Missing or invalid XML node",
element: "CommandKey",
});
commandKey = "";
}
if (!results) {
warnings.push({
message: "Missing or invalid XML node",
element: "Results",
});
results = [];
}
return {
name: "DUStateChangeComplete",
commandKey: commandKey,
results: results,
};
}
function DUStateChangeCompleteResponse(): string {
return "<cwmp:DUStateChangeCompleteResponse></cwmp:DUStateChangeCompleteResponse>";
}
function RequestDownload(xml: Element): RequestDownloadRequest {
return {
name: "RequestDownload",
@@ -894,6 +1040,9 @@ export function request(
case "TransferComplete":
rpc.cpeRequest = TransferComplete(methodElement);
break;
case "DUStateChangeComplete":
rpc.cpeRequest = DUStateChangeComplete(methodElement);
break;
case "RequestDownload":
rpc.cpeRequest = RequestDownload(methodElement);
break;
@@ -927,6 +1076,9 @@ export function request(
case "DownloadResponse":
rpc.cpeResponse = DownloadResponse(methodElement);
break;
case "ChangeDUStateResponse":
rpc.cpeResponse = ChangeDUStateResponse();
break;
case "Fault":
rpc.cpeFault = fault(methodElement);
break;
@@ -980,6 +1132,9 @@ export function response(rpc: {
case "TransferCompleteResponse":
body = TransferCompleteResponse();
break;
case "DUStateChangeCompleteResponse":
body = DUStateChangeCompleteResponse();
break;
case "RequestDownloadResponse":
body = RequestDownloadResponse();
break;
@@ -1022,6 +1177,9 @@ export function response(rpc: {
case "Download":
body = Download(rpc.acsRequest);
break;
case "ChangeDUState":
body = ChangeDUState(rpc.acsRequest);
break;
default:
throw new Error(
`Unknown method request ${(rpc.acsRequest as AcsRequest).name}`
+65 -5
View File
@@ -121,6 +121,10 @@ export interface SyncState {
downloadsToCreate: InstanceSet;
downloadsValues: Map<Path, string | number>;
downloadsDownload: Map<Path, number>;
duStatesToDelete: Set<Path>;
duStatesToCreate: InstanceSet;
duStatesValues: Map<Path, string | number>;
duStatesChangeDUState: Map<Path, number>;
reboot: number;
factoryReset: number;
}
@@ -183,12 +187,17 @@ export interface Task {
provisions?: (string | number | boolean)[][];
}
export interface Operation {
interface OperationBase {
name: string;
timestamp: number;
provisions: string[][];
channels: { [channel: string]: number };
retries: { [channel: string]: number };
args: any;
}
export interface DownloadOperation extends OperationBase {
name: "Download";
args: {
instance: string;
fileType: string;
@@ -197,6 +206,15 @@ export interface Operation {
};
}
export interface ChangeDUStateOperation extends OperationBase {
name: "ChangeDUState";
args: {
operations: ChangeDUState["operations"];
};
}
export type Operation = DownloadOperation | ChangeDUStateOperation;
export type AcsRequest =
| GetParameterNames
| GetParameterValues
@@ -207,7 +225,8 @@ export type AcsRequest =
| DeleteObject
| FactoryReset
| Reboot
| Download;
| Download
| ChangeDUState;
export interface GetParameterNames {
name: "GetParameterNames";
@@ -277,6 +296,21 @@ export interface Download {
failureUrl?: string;
}
export interface ChangeDUState {
name: "ChangeDUState";
commandKey: string;
operations: {
operationType: "install" | "update" | "uninstall";
instance: string;
url: string;
uuid: string;
version: string;
executionEnvRef: string;
username: string;
password: string;
}[];
}
export interface SpvFault {
parameterName: string;
faultCode: string;
@@ -305,7 +339,8 @@ export type CpeResponse =
| DeleteObjectResponse
| RebootResponse
| FactoryResetResponse
| DownloadResponse;
| DownloadResponse
| ChangeDUStateResponse;
export interface GetParameterNamesResponse {
name: "GetParameterNamesResponse";
@@ -342,6 +377,10 @@ export interface DeleteObjectResponse {
status: number;
}
export interface ChangeDUStateResponse {
name: "ChangeDUStateResponse";
}
export interface RebootResponse {
name: "RebootResponse";
}
@@ -361,7 +400,8 @@ export type CpeRequest =
| InformRequest
| TransferCompleteRequest
| GetRPCMethodsRequest
| RequestDownloadRequest;
| RequestDownloadRequest
| DUStateChangeCompleteRequest;
export interface InformRequest {
name: "Inform";
@@ -384,6 +424,22 @@ export interface TransferCompleteRequest {
completeTime?: number;
}
export interface DUStateChangeCompleteRequest {
name: "DUStateChangeComplete";
commandKey: string;
results: {
uuid: string;
deploymentUnitRef: string;
version: string;
currentState: string;
resolved: boolean;
executionUnitRefList: string;
fault?: { faultCode: string; faultString: string };
startTime?: number;
completeTime?: number;
}[];
}
export interface GetRPCMethodsRequest {
name: "GetRPCMethods";
}
@@ -397,7 +453,8 @@ export type AcsResponse =
| InformResponse
| GetRPCMethodsResponse
| TransferCompleteResponse
| RequestDownloadResponse;
| RequestDownloadResponse
| DUStateChangeCompleteResponse;
export interface InformResponse {
name: "InformResponse";
@@ -415,6 +472,9 @@ export interface TransferCompleteResponse {
export interface RequestDownloadResponse {
name: "RequestDownloadResponse";
}
export interface DUStateChangeCompleteResponse {
name: "DUStateChangeCompleteResponse";
}
export interface QueryOptions {
projection?: any;
+3 -1
View File
@@ -59,8 +59,10 @@ const getAuthorizer = memoize(
}
);
koa.on("error", async (err) => {
koa.on("error", async (err, ctx) => {
setTimeout(() => {
// Ignored errors resulting from aborted requests
if (ctx && ctx.req.aborted) return;
throw err;
});
});
+1 -1
View File
@@ -659,7 +659,7 @@ router.get("/ping/:host", async (ctx) => {
ctx.body = parsed;
} else {
ctx.status = 500;
ctx.body = `${err.name}: ${err.message}`;
ctx.body = err ? `${err.name}: ${err.message}` : "Unknown error";
}
resolve();
});
+19
View File
@@ -1,6 +1,25 @@
import ava from "ava";
import Path from "../lib/common/path";
ava("parse", (t) => {
t.throws(() => Path.parse("."));
t.throws(() => Path.parse("a "));
t.throws(() => Path.parse(".a"));
t.throws(() => Path.parse("a."));
t.throws(() => Path.parse("a..b"));
t.throws(() => Path.parse("b*"));
t.throws(() => Path.parse("*b"));
t.throws(() => Path.parse("a.b c.d"));
t.throws(() => Path.parse("a["));
t.throws(() => Path.parse("a[b"));
t.throws(() => Path.parse("a[b:"));
t.throws(() => Path.parse('a[b:"waef]'));
t.notThrows(() => Path.parse("[]"));
t.notThrows(() => Path.parse("[a:]"));
t.notThrows(() => Path.parse("*"));
t.notThrows(() => Path.parse(""));
});
ava("toString", (t) => {
const path1 = Path.parse('abc.[ abc : 123 , 123 : abc , 123: " abc "].123');
const path2 = Path.parse('abc.[123: " abc ",abc:123,123:abc].123');
+2 -2
View File
@@ -78,7 +78,7 @@ const component: ClosureComponent = (): Component => {
const { value, timestamp, parameter } = evaluateParam(
vnode.attrs["parameter"],
vnode.attrs["device"],
store.getTimestamp()
store.getTimestamp() + store.getClockSkew()
);
if (value == null) return null;
@@ -117,7 +117,7 @@ const component: ClosureComponent = (): Component => {
e.redraw = false;
// Don't update any child element
if (e.target === (el as VnodeDOM).dom) {
const now = Date.now();
const now = Date.now() + store.getClockSkew();
const localeString = new Date(timestamp).toLocaleString();
e.target.title = `${localeString} (${timeAgo(now - timestamp)})`;
}
+10
View File
@@ -787,3 +787,13 @@ span.parameter-value {
background-size: 100% 100%;
}
}
.progress {
background-color: #eee;
overflow: hidden;
height: 3px;
& > .progress-bar {
height: 100%;
background-color: #b12d5c;
}
}
+88 -79
View File
@@ -73,58 +73,32 @@ const unpackSmartQuery = memoize((query) => {
});
});
interface ValidationErrors {
[prop: string]: string;
}
function putActionHandler(action, _object): Promise<ValidationErrors> {
return new Promise((resolve, reject) => {
const object = Object.assign({}, _object);
if (action === "save") {
const file = object["file"] ? object["file"][0] : null;
delete object["file"];
if (!file) return void resolve({ file: "File not selected" });
const id = file.name;
store
.resourceExists("files", id)
.then((exists) => {
if (exists) {
store.setTimestamp(Date.now());
return void resolve({ file: "File already exists" });
}
const headers = Object.assign(
{
"Content-Type": "application/octet-stream",
Accept: "application/octet-stream",
},
object
);
store
.xhrRequest({
method: "PUT",
headers: headers,
url: `api/files/${encodeURIComponent(id)}`,
serialize: (body) => body, // Identity function to prevent JSON.parse on blob data
body: file,
})
.then(() => {
notifications.push(
"success",
`File ${exists ? "updated" : "created"}`
);
store.setTimestamp(Date.now());
resolve(null);
})
.catch(reject);
})
.catch(reject);
} else {
reject(new Error("Undefined action"));
}
function upload(
file: File,
headers: Record<string, string>,
abortSignal?: AbortSignal,
progressListener?: (e: ProgressEvent) => void
): Promise<void> {
headers = Object.assign(
{
"Content-Type": "application/octet-stream",
},
headers
);
return store.xhrRequest({
method: "PUT",
headers: headers,
url: `api/files/${encodeURIComponent(file.name)}`,
serialize: (body) => body, // Identity function to prevent JSON.parse on blob data
body: file,
config: (xhr) => {
if (progressListener)
xhr.upload.addEventListener("progress", progressListener);
if (abortSignal) {
if (abortSignal.aborted) xhr.abort();
abortSignal.addEventListener("abort", () => xhr.abort());
}
},
});
}
@@ -224,42 +198,77 @@ export const component: ClosureComponent = (): Component => {
title: "Create new file",
onclick: () => {
let cb: () => Children = null;
const abortController = new AbortController();
let progress = -1;
const comp = m(
putFormComponent,
Object.assign(
{
actionHandler: (action, object) => {
return new Promise<void>((resolve) => {
putActionHandler(action, object)
.then((errors) => {
const errorList = errors
? Object.values(errors)
: [];
if (errorList.length) {
for (const err of errorList)
notifications.push("error", err);
} else {
overlay.close(cb);
}
resolve();
})
.catch((err) => {
notifications.push("error", err.message);
resolve();
});
});
actionHandler: async (action, _object) => {
if (action !== "save")
throw new Error("Undefined action");
const object = Object.assign({}, _object);
const file = object["file"]
? object["file"][0]
: null;
delete object["file"];
if (!file) {
notifications.push("error", "File not selected");
return;
}
if (await store.resourceExists("files", file.name)) {
store.setTimestamp(Date.now());
notifications.push("error", "File already exists");
return;
}
const progressListener = (e: ProgressEvent): void => {
progress = e.loaded / e.total;
m.redraw();
};
progress = 0;
try {
await upload(
file,
object,
abortController.signal,
progressListener
);
store.setTimestamp(Date.now());
notifications.push("success", "File created");
overlay.close(cb);
} catch (err) {
notifications.push("error", err.message);
}
progress = -1;
},
},
formData
)
);
cb = () => comp;
overlay.open(
cb,
() =>
!comp.state["current"]["modified"] ||
confirm("You have unsaved changes. Close anyway?")
);
cb = () => {
if (progress < 0) return [null, comp];
return [
m(
"div.progress",
m("div.progress-bar", {
style: `width: ${Math.trunc(progress * 100)}%`,
})
),
comp,
];
};
overlay.open(cb, () => {
if (
comp.state["current"]["modified"] &&
!confirm("You have unsaved changes. Close anyway?")
)
return false;
abortController.abort();
return true;
});
},
},
"New"
+35 -6
View File
@@ -34,6 +34,8 @@ const memoizedEvaluate = memoize(evaluate);
let fulfillTimestamp = 0;
let connectionNotification, configNotification, versionNotification;
let clockSkew = 0;
const queries = {
filter: new WeakMap(),
bookmark: new WeakMap(),
@@ -93,11 +95,13 @@ class QueryResponse {
}
function checkConnection(): void {
const now1 = Date.now();
m.request({
url: "status",
method: "GET",
background: true,
extract: (xhr) => {
const now2 = Date.now();
if (xhr.status !== 200) {
if (!connectionNotification) {
connectionNotification = notifications.push(
@@ -112,6 +116,21 @@ function checkConnection(): void {
connectionNotification = null;
}
try {
const nowAvg = Math.trunc((now1 + now2) / 2);
const skew = Date.parse(xhr.getResponseHeader("Date")) - nowAvg;
if (Math.abs(skew - clockSkew) > 5000 && now2 - now1 < 1000) {
clockSkew = skew;
console.warn(
`System and server clocks are out of sync. Adding ${clockSkew}ms offset to any client-side time relative calculations.`
);
setTimestamp(now2);
m.redraw();
}
} catch (err) {
// Ignore in case of missing or invalid Date header
}
const configChanged =
xhr.getResponseHeader("x-config-snapshot") !== configSnapshot;
const versionChanged =
@@ -209,7 +228,7 @@ export async function xhrRequest(
export function unpackExpression(exp: Expression): Expression {
if (!Array.isArray(exp)) return exp;
const e = memoizedEvaluate(exp, null, fulfillTimestamp);
const e = memoizedEvaluate(exp, null, fulfillTimestamp + clockSkew);
return e;
}
@@ -295,7 +314,7 @@ function compareFunction(sort: {
function findMatches(resourceType, filter, sort, limit): any[] {
let value = [];
for (const obj of resources[resourceType].objects.values())
if (evaluate(filter, obj, fulfillTimestamp)) value.push(obj);
if (evaluate(filter, obj, fulfillTimestamp + clockSkew)) value.push(obj);
value = value.sort(compareFunction(sort));
if (limit) value = value.slice(0, limit);
@@ -461,7 +480,7 @@ export function fulfill(accessTimestamp: number): void {
for (const queryResponse of toFetch) {
let filter = queries.filter.get(queryResponse);
filter = memoizedEvaluate(filter, null, fulfillTimestamp);
filter = memoizedEvaluate(filter, null, fulfillTimestamp + clockSkew);
const bookmark = queries.bookmark.get(queryResponse);
const sort = queries.sort.get(queryResponse);
if (bookmark) filter = limitFilter(filter, sort, bookmark);
@@ -476,7 +495,11 @@ export function fulfill(accessTimestamp: number): void {
if (!diff) {
for (const queryResponse of toFetch) {
let filter = queries.filter.get(queryResponse);
filter = memoizedEvaluate(filter, null, fulfillTimestamp);
filter = memoizedEvaluate(
filter,
null,
fulfillTimestamp + clockSkew
);
const limit = queries.limit.get(queryResponse);
const bookmark = queries.bookmark.get(queryResponse);
const sort = queries.sort.get(queryResponse);
@@ -521,7 +544,9 @@ export function fulfill(accessTimestamp: number): void {
for (const d of deleted) {
const obj = resources[resourceType].objects.get(d);
if (evaluate(combinedFilterDiff, obj, fulfillTimestamp))
if (
evaluate(combinedFilterDiff, obj, fulfillTimestamp + clockSkew)
)
resources[resourceType].objects.delete(d);
}
@@ -563,6 +588,10 @@ export function setTimestamp(t: number): void {
}
}
export function getClockSkew(): number {
return clockSkew;
}
export function postTasks(
deviceId: string,
tasks: QueueTask[]
@@ -664,7 +693,7 @@ export function evaluateExpression(
obj: Record<string, unknown>
): Expression {
if (!Array.isArray(exp)) return exp;
return memoizedEvaluate(exp, obj, fulfillTimestamp);
return memoizedEvaluate(exp, obj, fulfillTimestamp + clockSkew);
}
export function changePassword(