Compare commits

...
2 Commits
Author SHA1 Message Date
Zaid Abdulla 4b0fc64403 Add more upload file types 2020-12-08 17:21:21 -08:00
Zaid Abdulla dae521ae89 Implement TR-069 Upload method 2020-12-08 17:21:18 -08:00
22 changed files with 1094 additions and 68 deletions

No files matched your search

+3 -1
View File
@@ -94,7 +94,9 @@ if (!cluster.worker) {
const _listener = (req, res): void => {
if (stopping) res.setHeader("Connection", "close");
listener(req, res);
listener(req, res).catch((err) => {
throw err;
});
};
const initPromise = db
+1
View File
@@ -333,5 +333,6 @@ export async function deleteDevice(deviceId: string): Promise<void> {
},
}),
cache.del(`${deviceId}_tasks_faults_operations`),
db.deleteDeviceUploads(deviceId),
]);
}
+20
View File
@@ -705,6 +705,11 @@ async function nextRpc(sessionContext: SessionContext): Promise<void> {
["download", task.fileType, task.fileName, task.targetFileName || ""],
]);
break;
case "upload":
session.addProvisions(sessionContext, `task_${task._id}`, [
["upload", task.fileType, task.fileName],
]);
break;
case "addObject":
alias = (task.parameterValues || [])
.map((p) => `${p[0]}:${JSON.stringify(p[1])}`)
@@ -850,6 +855,21 @@ async function sendAcsRequest(
}
}
if (acsRequest.name === "Upload") {
let prefix = "" + config.get("FS_URL_PREFIX");
if (!prefix) {
const FS_PORT = +config.get("FS_PORT");
const ssl = !!config.get("FS_SSL_CERT");
const origin = getRequestOrigin(sessionContext.httpRequest);
let hostname = origin.localAddress;
if (origin.host) [hostname] = origin.host.split(":", 1);
prefix = (ssl ? "https" : "http") + `://${hostname}:${FS_PORT}/`;
}
acsRequest.url = prefix + encodeURI(acsRequest.fileName);
}
const rpc = {
id: id,
acsRequest: acsRequest,
+28 -2
View File
@@ -17,7 +17,7 @@
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
import { MongoClient, ObjectID, Collection, Db } from "mongodb";
import { MongoClient, ObjectID, Collection, Db, GridFSBucket } from "mongodb";
import { get } from "./config";
import { encodeTag, escapeRegExp } from "./common";
import { parse } from "./common/expression-parser";
@@ -39,10 +39,12 @@ export let tasksCollection: Collection,
virtualParametersCollection: Collection,
faultsCollection: Collection,
filesCollection: Collection,
uploadsCollection: Collection,
operationsCollection: Collection,
permissionsCollection: Collection,
usersCollection: Collection,
configCollection: Collection;
configCollection: Collection,
uploadsBucket: GridFSBucket;
let clientPromise: Promise<MongoClient>;
@@ -77,6 +79,7 @@ onConnect(async (db) => {
presetsCollection = db.collection("presets");
objectsCollection = db.collection("objects");
filesCollection = db.collection("fs.files");
uploadsCollection = db.collection("uploads.files");
provisionsCollection = db.collection("provisions");
virtualParametersCollection = db.collection("virtualParameters");
faultsCollection = db.collection("faults");
@@ -84,6 +87,7 @@ onConnect(async (db) => {
permissionsCollection = db.collection("permissions");
usersCollection = db.collection("users");
configCollection = db.collection("config");
uploadsBucket = new GridFSBucket(db, { bucketName: "uploads" });
});
export async function disconnect(): Promise<void> {
@@ -439,6 +443,17 @@ export async function saveDevice(
break;
default:
if (
diff[0].segments[0] === "Uploads" &&
diff[0].segments[2] === "LastFileName" &&
value1 &&
value1 !== value2
) {
uploadsBucket.delete(value1 as any, () => {
// Ignore error due to mising files
});
}
if (!diff[2]) {
update["$unset"][path.toString()] = 1;
continue;
@@ -788,3 +803,14 @@ interface User {
export async function getUsers(): Promise<User[]> {
return usersCollection.find().toArray();
}
export async function deleteDeviceUploads(deviceId: string): Promise<void> {
const files = await uploadsCollection
.find({
_id: {
$regex: `^${escapeRegExp(deviceId)}\\/`,
},
})
.toArray();
await Promise.all(files.map((f) => uploadsBucket.delete(f["_id"])));
}
+38
View File
@@ -229,6 +229,44 @@ export function download(
return true;
}
export function upload(
sessionContext: SessionContext,
provision: (string | number | boolean)[],
declarations: Declaration[]
): boolean {
if (
provision.length !== 3 ||
typeof provision[1] !== "string" ||
typeof provision[2] !== "string"
)
throw new Error("Invalid arguments");
const alias = [
`FileType:${JSON.stringify(provision[1] || "")}`,
`FileName:${JSON.stringify(provision[2] || "")}`,
].join(",");
declarations.push({
path: Path.parse(`Uploads.[${alias}]`),
pathGet: 1,
pathSet: 1,
attrGet: null,
attrSet: null,
defer: true,
});
declarations.push({
path: Path.parse(`Uploads.[${alias}].Upload`),
pathGet: 1,
pathSet: null,
attrGet: { value: 1 },
attrSet: { value: [sessionContext.timestamp] },
defer: true,
});
return true;
}
export function instances(
sessionContext: SessionContext,
provision: (string | number | boolean)[],
+73 -23
View File
@@ -20,23 +20,42 @@
import * as url from "url";
import * as querystring from "querystring";
import { IncomingMessage, ServerResponse } from "http";
import { Collection, GridFSBucket } from "mongodb";
import { onConnect } from "./db";
import { Collection, GridFSBucket, ObjectId } from "mongodb";
import { onConnect, getOperations } from "./db";
import * as logger from "./logger";
import { getRequestOrigin } from "./forwarded";
let filesCollection: Collection;
let filesBucket: GridFSBucket;
let uploadsBucket: GridFSBucket;
onConnect(async (db) => {
filesCollection = db.collection("fs.files");
filesBucket = new GridFSBucket(db);
uploadsBucket = new GridFSBucket(db, { bucketName: "uploads" });
});
export function listener(
async function canUpload(
deviceId: string,
fileName: string,
timeout = Date.now() + 5000
): Promise<boolean> {
const operations = Object.values(await getOperations(deviceId));
for (const operation of operations) {
if (operation.name === "Upload" && operation.args.fileName === fileName)
return true;
}
if (Date.now() >= timeout) return false;
// Need to wait and retry in case upload was initiated before session was closed
await new Promise((resolve) => setTimeout(resolve, 1000));
return canUpload(deviceId, fileName, timeout);
}
export async function listener(
request: IncomingMessage,
response: ServerResponse
): void {
): Promise<void> {
const urlParts = url.parse(request.url, true);
if (request.method === "GET") {
const filename = querystring.unescape(urlParts.pathname.substring(1));
@@ -47,27 +66,58 @@ export function listener(
remoteAddress: getRequestOrigin(request).remoteAddress,
};
filesCollection.findOne({ _id: filename }, (err, file) => {
if (err) throw err;
const file = await filesCollection.findOne({ _id: filename });
if (!file) {
response.writeHead(404);
response.end();
log.message += " not found";
logger.accessError(log);
return;
}
if (!file) {
response.writeHead(404);
response.end();
log.message += " not found";
logger.accessError(log);
return;
}
response.writeHead(200, {
"Content-Type": file.contentType || "application/octet-stream",
"Content-Length": file.length,
});
const downloadStream = filesBucket.openDownloadStreamByName(filename);
downloadStream.pipe(response);
logger.accessInfo(log);
response.writeHead(200, {
"Content-Type": file.contentType || "application/octet-stream",
"Content-Length": file.length,
});
const downloadStream = filesBucket.openDownloadStreamByName(filename);
downloadStream.pipe(response);
logger.accessInfo(log);
} else if (request.method === "PUT") {
const [, deviceId, ...filePath] = urlParts.pathname
.split("/")
.map(querystring.unescape);
const fileName = `${deviceId}/${filePath.join("/")}`;
if (!(await canUpload(deviceId, fileName))) {
response.writeHead(403);
response.end("403 Forbidden");
return;
}
try {
await uploadsBucket.delete((fileName as unknown) as ObjectId);
} catch (err) {
// File not found, do nothing
}
const uploadStream = uploadsBucket.openUploadStreamWithId(
fileName,
fileName
);
uploadStream.on("finish", () => {
response.writeHead(200);
response.end();
});
uploadStream.on("error", (err) => {
response.writeHead(500);
response.end(err.message);
});
request.pipe(uploadStream);
} else {
response.writeHead(405, { Allow: "GET" });
response.end("405 Method Not Allowed");
+1
View File
@@ -108,6 +108,7 @@ export async function seed(options: Record<string, boolean>): Promise<void> {
{ role: "admin", resource: "devices", access: 3, validate: "true" },
{ role: "admin", resource: "faults", access: 3, validate: "true" },
{ role: "admin", resource: "files", access: 3, validate: "true" },
{ role: "admin", resource: "uploads", access: 3, validate: "true" },
{ role: "admin", resource: "presets", access: 3, validate: "true" },
{ role: "admin", resource: "provisions", access: 3, validate: "true" },
{ role: "admin", resource: "config", access: 3, validate: "true" },
+8
View File
@@ -640,6 +640,14 @@ export function flattenFile(
return f;
}
export function flattenUpload(
file: Record<string, unknown>
): Record<string, unknown> {
const f = {};
f["_id"] = file["_id"];
return f;
}
export function preProcessPreset(
data: Record<string, unknown>
): Record<string, unknown> {
+463 -36
View File
@@ -53,6 +53,7 @@ import {
AddObject,
DeleteObject,
Download,
Upload,
Reboot,
FactoryReset,
AddObjectResponse,
@@ -292,14 +293,15 @@ export async function transferComplete(
};
}
const instance = operation.args.instance;
delete sessionContext.operations[commandKey];
if (!sessionContext.operationsTouched) sessionContext.operationsTouched = {};
sessionContext.operationsTouched[commandKey] = 1;
if (rpcReq.faultStruct && rpcReq.faultStruct.faultCode !== "0") {
revertDownloadParameters(sessionContext, operation.args.instance);
if (operation.name === "Download")
revertDownloadParameters(sessionContext, operation.args.instance);
else if (operation.name === "Upload")
revertUploadParameters(sessionContext, operation.args.instance);
const fault: Fault = {
code: `cwmp.${rpcReq.faultStruct.faultCode}`,
@@ -318,6 +320,44 @@ export async function transferComplete(
let toClear = null;
const timestamp = sessionContext.timestamp + sessionContext.iteration + 1;
if (operation.name === "Download") {
toClear = commitDownloadOperation(
sessionContext,
operation,
rpcReq,
timestamp,
toClear
);
} else if (operation.name === "Upload") {
toClear = commitUploadOperation(
sessionContext,
operation,
rpcReq,
timestamp,
toClear
);
}
if (toClear) {
for (const c of toClear)
device.clear(sessionContext.deviceData, c[0], c[1], c[2], c[3]);
}
return {
acsResponse: { name: "TransferCompleteResponse" },
operation: operation,
fault: null,
};
}
function commitDownloadOperation(
sessionContext: SessionContext,
operation: Operation,
rpcReq: TransferCompleteRequest,
timestamp: number,
toClear: Clear[]
): Clear[] {
const instance = operation.args.instance;
let p;
p = sessionContext.deviceData.paths.add(
@@ -386,16 +426,110 @@ export async function transferComplete(
toClear
);
return toClear;
}
function commitUploadOperation(
sessionContext: SessionContext,
operation: Operation,
rpcReq: TransferCompleteRequest,
timestamp: number,
toClear: Clear[]
): Clear[] {
const instance = operation.args.instance;
let p;
p = sessionContext.deviceData.paths.add(
Path.parse(`Uploads.${instance}.LastUpload`)
);
toClear = device.set(
sessionContext.deviceData,
p,
timestamp,
{ value: [timestamp, [operation.timestamp, "xsd:dateTime"]] },
toClear
);
p = sessionContext.deviceData.paths.add(
Path.parse(`Uploads.${instance}.LastFileType`)
);
toClear = device.set(
sessionContext.deviceData,
p,
timestamp,
{ value: [timestamp, [operation.args.fileType, "xsd:string"]] },
toClear
);
p = sessionContext.deviceData.paths.add(
Path.parse(`Uploads.${instance}.LastFileName`)
);
toClear = device.set(
sessionContext.deviceData,
p,
timestamp,
{ value: [timestamp, [operation.args.fileName, "xsd:string"]] },
toClear
);
p = sessionContext.deviceData.paths.add(
Path.parse(`Uploads.${instance}.StartTime`)
);
toClear = device.set(
sessionContext.deviceData,
p,
timestamp,
{ value: [timestamp, [+rpcReq.startTime, "xsd:dateTime"]] },
toClear
);
p = sessionContext.deviceData.paths.add(
Path.parse(`Uploads.${instance}.CompleteTime`)
);
toClear = device.set(
sessionContext.deviceData,
p,
timestamp,
{ value: [timestamp, [+rpcReq.completeTime, "xsd:dateTime"]] },
toClear
);
return toClear;
}
function revertUploadParameters(
sessionContext: SessionContext,
instance
): void {
const timestamp = sessionContext.timestamp + sessionContext.iteration + 1;
let p;
p = sessionContext.deviceData.paths.add(
Path.parse(`Uploads.${instance}.LastUpload`)
);
const lastUpload = sessionContext.deviceData.attributes.get(p);
p = sessionContext.deviceData.paths.add(
Path.parse(`Uploads.${instance}.Upload`)
);
const toClear = device.set(sessionContext.deviceData, p, timestamp, {
value: [
timestamp,
[
lastUpload && lastUpload.value[1] ? lastUpload.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]);
}
return {
acsResponse: { name: "TransferCompleteResponse" },
operation: operation,
fault: null,
};
}
function revertDownloadParameters(
@@ -445,33 +579,62 @@ export async function timeoutOperations(
for (const [commandKey, operation] of Object.entries(
sessionContext.operations
)) {
if (operation.name !== "Download")
if (operation.name === "Download") {
const DOWNLOAD_TIMEOUT =
+localCache.getConfig(
sessionContext.cacheSnapshot,
"cwmp.downloadTimeout",
{},
sessionContext.timestamp,
(e) => configContextCallback(sessionContext, e)
) * 1000;
if (sessionContext.timestamp > operation.timestamp + DOWNLOAD_TIMEOUT) {
delete sessionContext.operations[commandKey];
if (!sessionContext.operationsTouched)
sessionContext.operationsTouched = {};
sessionContext.operationsTouched[commandKey] = 1;
faults.push({
code: "timeout",
message: "Download operation timed out",
timestamp: operation.timestamp,
});
operations.push(operation);
revertDownloadParameters(sessionContext, operation.args.instance);
}
} else if (operation.name === "Upload") {
const UPLOAD_TIMEOUT =
+(
localCache.getConfig(
sessionContext.cacheSnapshot,
"cwmp.uploadTimeout",
{},
sessionContext.timestamp,
(e) => configContextCallback(sessionContext, e)
) || 3600
) * 1000;
if (sessionContext.timestamp > operation.timestamp + UPLOAD_TIMEOUT) {
delete sessionContext.operations[commandKey];
if (!sessionContext.operationsTouched)
sessionContext.operationsTouched = {};
sessionContext.operationsTouched[commandKey] = 1;
faults.push({
code: "timeout",
message: "Upload operation timed out",
timestamp: operation.timestamp,
});
operations.push(operation);
revertUploadParameters(sessionContext, operation.args.instance);
}
} else {
throw new Error(`Unknown operation name ${operation.name}`);
const DOWNLOAD_TIMEOUT =
+localCache.getConfig(
sessionContext.cacheSnapshot,
"cwmp.downloadTimeout",
{},
sessionContext.timestamp,
(e) => configContextCallback(sessionContext, e)
) * 1000;
if (sessionContext.timestamp > operation.timestamp + DOWNLOAD_TIMEOUT) {
delete sessionContext.operations[commandKey];
if (!sessionContext.operationsTouched)
sessionContext.operationsTouched = {};
sessionContext.operationsTouched[commandKey] = 1;
faults.push({
code: "timeout",
message: "Download operation timed out",
timestamp: operation.timestamp,
});
operations.push(operation);
revertDownloadParameters(sessionContext, operation.args.instance);
}
}
@@ -793,6 +956,10 @@ function runDeclarations(
downloadsToCreate: new InstanceSet(),
downloadsValues: new Map(),
downloadsDownload: new Map(),
uploadsToDelete: new Set(),
uploadsToCreate: new InstanceSet(),
uploadsValues: new Map(),
uploadsUpload: new Map(),
reboot: 0,
factoryReset: 0,
};
@@ -1304,7 +1471,7 @@ export async function rpcRequest(
}
// Downloads
let index;
let index: number;
for (const instance of sessionContext.syncState.downloadsToCreate) {
if (index == null) {
index = 0;
@@ -1426,6 +1593,9 @@ export async function rpcRequest(
}
}
// Uploads
toClear = syncUploads(sessionContext, timestamp, toClear);
if (toClear || sessionContext.deviceData.changes.has("prerequisite")) {
if (toClear) {
for (const c of toClear)
@@ -1503,6 +1673,129 @@ export async function rpcRequest(
return rpcRequest(sessionContext, null);
}
function syncUploads(
sessionContext: SessionContext,
timestamp: number,
toClear: Clear[]
): Clear[] {
let index: number;
for (const instance of sessionContext.syncState.uploadsToCreate) {
if (index == null) {
index = 0;
for (const p of sessionContext.deviceData.paths.find(
Path.parse("Uploads.*"),
false,
true
)) {
if (
+p.segments[1] > index &&
sessionContext.deviceData.attributes.has(p)
)
index = +p.segments[1];
}
}
++index;
toClear = device.set(
sessionContext.deviceData,
Path.parse("Uploads"),
timestamp,
{ object: [timestamp, 1], writable: [timestamp, 1] },
toClear
);
toClear = device.set(
sessionContext.deviceData,
Path.parse(`Uploads.${index}`),
timestamp,
{ object: [timestamp, 1], writable: [timestamp, 1] },
toClear
);
const params = {
FileType: {
writable: 1,
value: [instance.FileType || "", "xsd:string"],
},
FileName: {
writable: 1,
value: [instance.FileName || "", "xsd:string"],
},
Upload: {
writable: 1,
value: [instance.Upload || 0, "xsd:dateTime"],
},
LastFileType: { writable: 0, value: ["", "xsd:string"] },
LastFileName: { writable: 0, value: ["", "xsd:string"] },
LastTargetFileName: { writable: 0, value: ["", "xsd:string"] },
LastUpload: { writable: 0, value: [0, "xsd:dateTime"] },
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(`Uploads.${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(`Uploads.${index}.*`),
timestamp,
null,
toClear
);
}
sessionContext.syncState.uploadsToCreate.clear();
for (const instance of sessionContext.syncState.uploadsToDelete) {
toClear = device.set(
sessionContext.deviceData,
instance,
timestamp,
null,
toClear
);
for (const p of sessionContext.syncState.uploadsValues.keys()) {
if (p.segments[1] === instance.segments[1])
sessionContext.syncState.uploadsValues.delete(p);
}
}
sessionContext.syncState.uploadsToDelete.clear();
for (const [p, v] of sessionContext.syncState.uploadsValues) {
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
);
}
}
}
}
return toClear;
}
function generateGetRpcRequest(
sessionContext: SessionContext
): GetParameterNames | GetParameterValues | GetParameterAttributes {
@@ -1728,6 +2021,7 @@ function generateSetRpcRequest(
| FactoryReset
| Reboot
| Download
| Upload
) & { next?: string } {
const syncState = sessionContext.syncState;
if (!syncState) return null;
@@ -1906,6 +2200,36 @@ function generateSetRpcRequest(
}
}
// Uploads
for (const [p, t] of syncState.uploadsUpload) {
const attrs = deviceData.attributes.get(p);
if (!(attrs && attrs.value && t <= attrs.value[1][0])) {
const fileTypeAttrs = deviceData.attributes.get(
deviceData.paths.get(p.slice(0, -1).concat(Path.parse("FileType")))
);
const fileNameAttrs = deviceData.attributes.get(
deviceData.paths.get(p.slice(0, -1).concat(Path.parse("FileName")))
);
if (fileNameAttrs && fileNameAttrs.value) {
return {
name: "Upload",
commandKey: generateRpcId(sessionContext),
instance: p.segments[1] as string,
fileType: fileTypeAttrs
? fileTypeAttrs.value
? (fileTypeAttrs.value[1][0] as string)
: null
: null,
fileName:
sessionContext.deviceId +
"/" +
(fileNameAttrs.value[1][0] as string),
};
}
}
}
// Reboot
if (syncState.reboot) {
const p = sessionContext.deviceData.paths.get(Path.parse("Reboot"));
@@ -2187,6 +2511,26 @@ function processDeclarations(
}
}
break;
case "Uploads":
if (
currentPath.length === 3 &&
currentPath.wildcard === 0 &&
declareAttributeValues &&
declareAttributeValues.value
) {
if (currentPath.segments[2] === "Upload") {
syncState.uploadsUpload.set(
currentPath,
declareAttributeValues.value[0]
);
} else {
syncState.uploadsValues.set(
currentPath,
declareAttributeValues.value[0]
);
}
}
break;
case "VirtualParameters":
if (currentPath.length <= 2) {
let d;
@@ -2357,6 +2701,10 @@ function processInstances(
if (parent.length !== 1) return;
instancesToDelete = sessionContext.syncState.downloadsToDelete;
instancesToCreate = sessionContext.syncState.downloadsToCreate;
} else if (parent.segments[0] === "Uploads") {
if (parent.length !== 1) return;
instancesToDelete = sessionContext.syncState.uploadsToDelete;
instancesToCreate = sessionContext.syncState.uploadsToCreate;
} else {
instancesToDelete = sessionContext.syncState.instancesToDelete.get(parent);
if (instancesToDelete == null) {
@@ -2578,6 +2926,7 @@ export async function rpcResponse(
"FactoryReset",
"VirtualParameters",
"Downloads",
"Uploads",
]) {
const p = sessionContext.deviceData.paths.get(Path.parse(n));
if (p && sessionContext.deviceData.attributes.has(p))
@@ -2788,6 +3137,84 @@ export async function rpcResponse(
operation.retries[channel] = sessionContext.retries[channel];
}
sessionContext.operations[rpcReq.commandKey] = operation;
if (!sessionContext.operationsTouched)
sessionContext.operationsTouched = {};
sessionContext.operationsTouched[rpcReq.commandKey] = 1;
}
} else if (rpcRes.name === "UploadResponse") {
if (rpcReq.name !== "Upload")
throw new Error("Response name does not match request name");
toClear = device.set(
sessionContext.deviceData,
Path.parse(`Uploads.${rpcReq.instance}.Upload`),
timestamp + 1,
{ value: [timestamp + 1, [sessionContext.timestamp, "xsd:dateTime"]] },
toClear
);
if (rpcRes.status === 0) {
toClear = device.set(
sessionContext.deviceData,
Path.parse(`Uploads.${rpcReq.instance}.LastUpload`),
timestamp + 1,
{
value: [timestamp + 1, [sessionContext.timestamp, "xsd:dateTime"]],
},
toClear
);
toClear = device.set(
sessionContext.deviceData,
Path.parse(`Uploads.${rpcReq.instance}.LastFileType`),
timestamp + 1,
{ value: [timestamp + 1, [rpcReq.fileType, "xsd:string"]] },
toClear
);
toClear = device.set(
sessionContext.deviceData,
Path.parse(`Uploads.${rpcReq.instance}.LastFileName`),
timestamp + 1,
{ value: [timestamp + 1, [rpcReq.fileType, "xsd:string"]] },
toClear
);
toClear = device.set(
sessionContext.deviceData,
Path.parse(`Uploads.${rpcReq.instance}.StartTime`),
timestamp + 1,
{ value: [timestamp + 1, [+rpcRes.startTime, "xsd:dateTime"]] },
toClear
);
toClear = device.set(
sessionContext.deviceData,
Path.parse(`Uploads.${rpcReq.instance}.CompleteTime`),
timestamp + 1,
{ value: [timestamp + 1, [+rpcRes.completeTime, "xsd:dateTime"]] },
toClear
);
} else {
const operation = {
name: "Upload",
timestamp: sessionContext.timestamp,
provisions: sessionContext.provisions,
channels: sessionContext.channels,
retries: {},
args: {
instance: rpcReq.instance,
fileType: rpcReq.fileType,
fileName: rpcReq.fileName,
},
};
for (const channel of Object.keys(sessionContext.channels)) {
if (sessionContext.retries[channel] != null)
operation.retries[channel] = sessionContext.retries[channel];
}
sessionContext.operations[rpcReq.commandKey] = operation;
if (!sessionContext.operationsTouched)
sessionContext.operationsTouched = {};
+54
View File
@@ -45,6 +45,7 @@ import {
RebootResponse,
FactoryResetResponse,
DownloadResponse,
UploadResponse,
GetRPCMethodsRequest,
RequestDownloadRequest,
AcsResponse,
@@ -496,6 +497,24 @@ function Download(methodRequest): string {
)}</FailureURL></cwmp:Download>`;
}
function Upload(methodRequest): string {
return `<cwmp:Upload><CommandKey>${
methodRequest.commandKey || ""
}</CommandKey><FileType>${methodRequest.fileType}</FileType><URL>${
methodRequest.url
}</URL><Username>${encodeEntities(
methodRequest.username || ""
)}</Username><Password>${encodeEntities(
methodRequest.password || ""
)}</Password><DelaySeconds>${
methodRequest.delaySeconds || "0"
}</DelaySeconds><SuccessURL>${encodeEntities(
methodRequest.successUrl || ""
)}</SuccessURL><FailureURL>${encodeEntities(
methodRequest.failureUrl || ""
)}</FailureURL></cwmp:Upload>`;
}
function DownloadResponse(xml: Element): DownloadResponse {
let status: number, startTime: number, completeTime: number;
for (const c of xml.children) {
@@ -544,6 +563,35 @@ function DownloadResponse(xml: Element): DownloadResponse {
};
}
function UploadResponse(xml: Element): UploadResponse {
let status: number, startTime: number, completeTime: number;
for (const c of xml.children) {
switch (c.localName) {
case "Status":
status = parseInt(c.text);
break;
case "StartTime":
startTime = Date.parse(c.text);
break;
case "CompleteTime":
completeTime = Date.parse(c.text);
break;
}
}
if (!(status >= 0)) {
warnings.push({ message: "Invalid UploadResponse status" });
status = 0;
}
return {
name: "UploadResponse",
status: status,
startTime: startTime,
completeTime: completeTime,
};
}
function Inform(xml: Element): InformRequest {
let retryCount: number, evnt: string[];
let parameterList: [Path, string | number | boolean, string][];
@@ -927,6 +975,9 @@ export function request(
case "DownloadResponse":
rpc.cpeResponse = DownloadResponse(methodElement);
break;
case "UploadResponse":
rpc.cpeResponse = UploadResponse(methodElement);
break;
case "Fault":
rpc.cpeFault = fault(methodElement);
break;
@@ -1022,6 +1073,9 @@ export function response(rpc: {
case "Download":
body = Download(rpc.acsRequest);
break;
case "Upload":
body = Upload(rpc.acsRequest);
break;
default:
throw new Error(
`Unknown method request ${(rpc.acsRequest as AcsRequest).name}`
+30 -3
View File
@@ -121,6 +121,10 @@ export interface SyncState {
downloadsToCreate: InstanceSet;
downloadsValues: Map<Path, string | number>;
downloadsDownload: Map<Path, number>;
uploadsToDelete: Set<Path>;
uploadsToCreate: InstanceSet;
uploadsValues: Map<Path, string | number>;
uploadsUpload: Map<Path, number>;
reboot: number;
factoryReset: number;
}
@@ -193,7 +197,7 @@ export interface Operation {
instance: string;
fileType: string;
fileName: string;
targetFileName: string;
targetFileName?: string;
};
}
@@ -207,7 +211,8 @@ export type AcsRequest =
| DeleteObject
| FactoryReset
| Reboot
| Download;
| Download
| Upload;
export interface GetParameterNames {
name: "GetParameterNames";
@@ -277,6 +282,20 @@ export interface Download {
failureUrl?: string;
}
export interface Upload {
name: "Upload";
commandKey: string;
instance: string;
fileType: string;
fileName?: string;
url?: string;
username?: string;
password?: string;
delaySecods?: number;
successUrl?: string;
failureUrl?: string;
}
export interface SpvFault {
parameterName: string;
faultCode: string;
@@ -305,7 +324,8 @@ export type CpeResponse =
| DeleteObjectResponse
| RebootResponse
| FactoryResetResponse
| DownloadResponse;
| DownloadResponse
| UploadResponse;
export interface GetParameterNamesResponse {
name: "GetParameterNamesResponse";
@@ -357,6 +377,13 @@ export interface DownloadResponse {
completeTime?: number;
}
export interface UploadResponse {
name: "UploadResponse";
status: number;
startTime?: number;
completeTime?: number;
}
export type CpeRequest =
| InformRequest
| TransferCompleteRequest
+4
View File
@@ -58,6 +58,10 @@ export async function deleteResource(
await db.deleteFile(id);
break;
case "uploads":
await db.deleteUpload(id);
break;
case "faults":
await deleteFault(id);
break;
+38 -1
View File
@@ -49,6 +49,7 @@ const RESOURCE_IDS = {
presets: "_id",
provisions: "_id",
files: "_id",
uploads: "_id",
virtualParameters: "_id",
config: "_id",
permissions: "_id",
@@ -62,6 +63,7 @@ const resources = {
presets: 0 | RESOURCE_DELETE | RESOURCE_PUT,
provisions: 0 | RESOURCE_DELETE | RESOURCE_PUT,
files: 0 | RESOURCE_DELETE,
uploads: 0 | RESOURCE_DELETE,
virtualParameters: 0 | RESOURCE_DELETE | RESOURCE_PUT,
config: 0 | RESOURCE_DELETE | RESOURCE_PUT,
permissions: 0 | RESOURCE_DELETE | RESOURCE_PUT,
@@ -361,7 +363,7 @@ for (const [resource, flags] of Object.entries(resources)) {
});
if (flags & RESOURCE_DELETE) {
router.delete(`/${resource}/:id`, async (ctx) => {
router.delete(`/${resource}/:id(.+)`, async (ctx) => {
const authorizer: Authorizer = ctx.state.authorizer;
const log = {
message: `Delete ${resource}`,
@@ -478,6 +480,41 @@ router.put("/files/:id", async (ctx) => {
ctx.body = "";
});
router.get("/uploads/blob/:id(.+)", async (ctx) => {
const authorizer: Authorizer = ctx.state.authorizer;
const resource = "uploads";
const id = ctx.params.id;
const log = {
message: `Download ${resource} blob`,
context: ctx,
id: id,
};
1;
const filter = and(authorizer.getFilter(resource, 2), [
"=",
["PARAM", RESOURCE_IDS[resource]],
id,
]);
if (!authorizer.hasAccess(resource, 2)) {
logUnauthorizedWarning(log);
return void (ctx.status = 403);
}
const res = await db.query(resource, filter);
if (!res[0]) return void (ctx.status = 404);
ctx.type = "application/octet-stream";
ctx.attachment(id.replace(/\//g, "_"));
ctx.body = new stream.PassThrough();
const downloadStream = await db.getUploadBlob(id);
downloadStream.pipe(ctx.body);
logger.accessInfo(log);
});
router.post("/devices/:id/tasks", async (ctx) => {
const authorizer: Authorizer = ctx.state.authorizer;
const log = {
+15
View File
@@ -28,6 +28,7 @@ import { minimize } from "../common/boolean-expression";
const RESOURCE_COLLECTION = {
files: "fs.files",
uploads: "uploads.files",
};
let db: Db;
@@ -112,6 +113,8 @@ export function query(
docs = docs.map((d) => mongodbFunctions.flattenPreset(d));
else if (resource === "files")
docs = docs.map((d) => mongodbFunctions.flattenFile(d));
else if (resource === "uploads")
docs = docs.map((d) => mongodbFunctions.flattenUpload(d));
return resolve(docs);
});
} else {
@@ -126,6 +129,8 @@ export function query(
doc = mongodbFunctions.flattenPreset(doc);
else if (resource === "files")
doc = mongodbFunctions.flattenFile(doc);
else if (resource === "uploads")
doc = mongodbFunctions.flattenUpload(doc);
callback(doc);
},
(err) => {
@@ -326,6 +331,16 @@ export function deleteFile(filename: string): Promise<void> {
});
}
export async function deleteUpload(filename: string): Promise<void> {
const bucket = new GridFSBucket(db, { bucketName: "uploads" });
await bucket.delete(filename as any);
}
export async function getUploadBlob(filename: string): Promise<Readable> {
const bucket = new GridFSBucket(db, { bucketName: "uploads" });
return bucket.openDownloadStreamByName(filename);
}
export function deleteFault(id: string): Promise<void> {
return deleteResource("faults", id);
}
+2
View File
@@ -39,6 +39,7 @@ import tags from "./components/tags";
import ping from "./components/ping";
import deviceLink from "./components/device-link";
import longTextComponent from "./long-text-component";
import deviceUploads from "./components/device-uploads";
const comps = {
parameter,
@@ -54,6 +55,7 @@ const comps = {
ping,
"device-link": deviceLink,
"long-text": longTextComponent,
"device-uploads": deviceUploads,
};
const contextifiedComponents = new WeakMap<ComponentTypes, ComponentTypes>();
+25
View File
@@ -22,6 +22,7 @@ import { m } from "../components";
import * as taskQueue from "../task-queue";
import * as notifications from "../notifications";
import * as store from "../store";
import * as overlay from "../overlay";
const component: ClosureComponent = (): Component => {
return {
@@ -78,6 +79,30 @@ const component: ClosureComponent = (): Component => {
)
);
buttons.push(
m(
"button.primary",
{
title: "Fetch files from device",
onclick: () => {
const deviceId = device["DeviceID.ID"].value[0];
overlay.open(() => {
const dev = store.fetch("devices", [
"=",
["PARAM", "DeviceID.ID"],
deviceId,
]);
return [
m("h3", `Uploads from ${deviceId}`),
m("device-uploads", { device: dev.value[0] }),
];
});
},
},
"Uploads"
)
);
buttons.push(
m(
"button.primary",
+155
View File
@@ -0,0 +1,155 @@
/**
* Copyright 2013-2020 GenieACS Inc.
*
* This file is part of GenieACS.
*
* GenieACS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* GenieACS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
import { ClosureComponent, Component } from "mithril";
import { m } from "../components";
import * as store from "../store";
import * as notifications from "../notifications";
import { getIcon } from "../icons";
import * as taskQueue from "../task-queue";
const component: ClosureComponent = (): Component => {
return {
view: (vnode) => {
const device = vnode.attrs["device"];
const deviceId = device["DeviceID.ID"].value[0];
const files = store.fetch("uploads", [
"AND",
[">", ["PARAM", "_id"], `${deviceId}/`],
["<", ["PARAM", "_id"], `${deviceId}/zzzz`],
]);
const uploads: Record<string, any> = {};
for (const [k, v] of Object.entries(device)) {
if (k.startsWith("Uploads.")) {
const parts = k.split(".");
if (parts.length !== 3) continue;
uploads[parts[1]] = uploads[parts[1]] || {};
uploads[parts[1]][parts[2]] = v["value"][0];
}
}
const headers = ["Filename", "Type", "Timestamp", "Status"].map((l) =>
m("th", l)
);
const thead = m("thead", m("tr", headers));
let minTime = 999999;
const timestamp = store.getTimestamp();
const rows = [];
const uploadsSorted = Object.values(uploads).sort(
(a, b) => a["Upload"] - b["Upload"]
);
for (const u of Object.values(uploadsSorted)) {
if (!u["Upload"]) continue;
const ready = u["LastUpload"] >= u["Upload"];
if (!ready) {
minTime = Math.min(minTime, u["Upload"] - timestamp);
rows.push([
m("td", u["FileName"]),
m("td", u["FileType"]),
m("td", new Date(u["Upload"]).toLocaleString()),
m("td", "Waiting for upload"),
]);
} else {
const filePath = `${deviceId}/${u["FileName"]}`;
if (!Array.isArray(files.value)) continue;
if (!files.value.some((f) => f["_id"] === filePath)) continue;
const deleteButton = m(
"button",
{
title: "Delete file",
onclick: (e) => {
e.redraw = false;
store
.deleteResource("uploads", filePath)
.then(() => {
notifications.push("success", "File deleted");
store.fulfill(Date.now(), Date.now());
m.redraw();
})
.catch((err) => {
notifications.push("error", err.message);
store.fulfill(Date.now(), Date.now());
});
},
},
getIcon("delete-instance")
);
rows.push([
m("td", u["FileName"]),
m("td", u["FileType"]),
m("td", new Date(u["LastUpload"]).toLocaleString()),
m(
"td",
m(
"a",
{
href: `api/uploads/blob/${encodeURIComponent(filePath)}`,
},
"Ready"
)
),
m("td", deleteButton),
]);
}
}
if (minTime < 60000) {
setTimeout(() => {
store.fulfill(0, timestamp + 5000);
}, 10000);
}
const addButton = m(
"button",
{
title: "Fetch a new file",
onclick: () => {
taskQueue.stageUpload({
name: "upload",
devices: [deviceId],
});
},
},
getIcon("add-instance")
);
const empty = !rows.length;
rows.push([m("td", { colspan: headers.length }), m("td", addButton)]);
const tbody = m(
"tbody",
rows.map((r) => m("tr", r)),
empty
? m("tr.empty", m("td", { colspan: headers.length }, "No uploads"))
: null
);
return m("table.table", thead, tbody);
},
};
};
export default component;
+1 -1
View File
@@ -106,7 +106,7 @@ body {
& > .drawer-wrapper {
position: fixed;
z-index: 10;
z-index: 101;
width: 720px;
margin-left: -360px;
top: 0;
+125
View File
@@ -184,6 +184,122 @@ function renderStagingDownload(task: StageTask): Children {
];
}
const uploadTypesForDevice: WeakMap<
Record<string, unknown>,
Record<string, string>
> = new WeakMap();
function getUploadTypesForDevice(
device: Record<string, unknown>
): Record<string, string> {
if (uploadTypesForDevice.has(device)) return uploadTypesForDevice.get(device);
const instances: Set<string> = new Set();
const filenames: Record<string, string> = {};
const versions: Record<string, string> = {};
if (device) {
for (const [k, v] of Object.entries(device)) {
if (/DeviceInfo\.VendorConfigFile\.[0-9]+(\.Name|\.Version)?$/.test(k)) {
const p = k.split(".");
if (p[p.length - 1] === "Name") {
const n = `3 Vendor Configuration File ${p[p.length - 2]}`;
instances.add(n);
if (v["value"]) filenames[n] = v["value"][0];
} else if (p[p.length - 1] === "Version") {
const n = `3 Vendor Configuration File ${p[p.length - 2]}`;
instances.add(n);
if (v["value"]) versions[n] = v["value"][0];
} else {
const n = `3 Vendor Configuration File ${p[p.length - 1]}`;
instances.add(n);
}
} else if (/DeviceInfo\.VendorLogFile\.[0-9]+(\.Name)?$/.test(k)) {
const p = k.split(".");
if (p[p.length - 1] === "Name") {
const n = `4 Vendor Log File ${p[p.length - 2]}`;
instances.add(n);
if (v["value"]) filenames[n] = v["value"][0];
} else {
const n = `4 Vendor Log File ${p[p.length - 1]}`;
instances.add(n);
}
}
}
}
if (!instances.size) {
instances.add("1 Vendor Configuration File");
instances.add("2 Vendor Log File");
}
const res: Record<string, string> = {};
for (const i of instances) {
let n = filenames[i];
const v = versions[i];
if (!n) n = i.replace(/\s+/g, "_") + ".txt";
if (v) n = `${n}-${v}`;
res[i] = n;
}
uploadTypesForDevice.set(device, res);
return res;
}
function renderStagingUpload(task: StageTask): Children {
if (!task.fileName || !task.fileType) invalid.add(task);
else invalid.delete(task);
const commonTypes: Record<string, number> = {};
const filenames: Record<string, string> = {};
for (const d of task.devices) {
const dev = store.fetch("devices", ["=", ["PARAM", "DeviceID.ID"], d])
.value[0];
if (dev) {
const u = getUploadTypesForDevice(dev);
Object.assign(filenames, u);
for (const k of Object.keys(u))
commonTypes[k] = (commonTypes[k] || 0) + 1;
}
}
const typesList = [""];
typesList.push(
...Object.entries(commonTypes)
.filter(([, c]) => c === task.devices.length)
.map(([k]) => k)
.sort()
);
return [
"Fetch ",
m(
"select",
{
onchange: (e) => {
task.fileType = e.target.value;
task.fileName = filenames[task.fileType];
},
},
typesList.map((t) =>
m(
"option",
{ disabled: !t, value: t, selected: (task.fileType || "") === t },
t
)
)
),
" as ",
m("input", {
onchange: (e) => {
task.fileName = e.target.value;
},
value: task.fileName || "",
}),
];
}
function renderStaging(staging: Set<StageTask>): Child[] {
const elements: Child[] = [];
@@ -204,6 +320,7 @@ function renderStaging(staging: Set<StageTask>): Child[] {
if (s.name === "setParameterValues")
elms = renderStagingSpv(s, queueFunc, cancelFunc);
else if (s.name === "download") elms = renderStagingDownload(s);
else if (s.name === "upload") elms = renderStagingUpload(s);
const queue = m(
"button.primary",
@@ -323,6 +440,14 @@ function renderQueue(queue: Set<QueueTask>): Child[] {
m(".actions", actions)
)
);
} else if (t.name === "upload") {
details.push(
m(
`div.${t.status}`,
`Fetch file ${t.fileName} (${t.fileType})`,
m(".actions", actions)
)
);
} else {
details.push(m(`div.${t.status}`, t.name, m(".actions", actions)));
}
+1
View File
@@ -47,6 +47,7 @@ const attributes = [
"devices",
"faults",
"files",
"uploads",
"permissions",
"users",
"presets",
+1 -1
View File
@@ -58,11 +58,11 @@ const resources: Resources = {};
for (const r of [
"devices",
"faults",
"files",
"presets",
"provisions",
"virtualParameters",
"files",
"uploads",
"config",
"users",
"permissions",
+8
View File
@@ -89,6 +89,14 @@ export function stageDownload(task: StageTask): void {
staging.add(task);
}
export function stageUpload(task: StageTask): void {
if (queue.size + task.devices.length > MAX_QUEUE) {
notifications.push("error", "Too many tasks in queue");
return;
}
staging.add(task);
}
export function commit(
tasks: QueueTask[],
callback: (