From 41229ffb85345acb9044843f48068008dbe2f4da Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Tue, 25 Sep 2018 17:12:49 +0300 Subject: [PATCH] Add files page --- client/app.js | 4 +- client/components/put-form.js | 26 ++- client/files-page.js | 370 ++++++++++++++++++++++++++++++++++ client/menu.js | 9 + client/smart-query.js | 10 + client/store.js | 3 +- config.json | 5 +- server/api-functions.js | 19 ++ server/api.js | 55 +++++ server/db.js | 8 +- server/mongodb-functions.js | 13 ++ server/server.js | 7 + 12 files changed, 521 insertions(+), 8 deletions(-) create mode 100644 client/files-page.js diff --git a/client/app.js b/client/app.js index 629dccc..fe5e468 100644 --- a/client/app.js +++ b/client/app.js @@ -13,6 +13,7 @@ import * as faultsPage from "./faults-page"; import * as presetsPage from "./presets-page"; import * as provisionsPage from "./provisions-page"; import * as virtualParametersPage from "./virtual-parameters-page"; +import * as filesPage from "./files-page"; import Authorizer from "../common/authorizer"; import * as notifications from "./notifications"; @@ -84,5 +85,6 @@ m.route(document.body, "/overview", { "/faults": pagify("faults", faultsPage), "/presets": pagify("presets", presetsPage), "/provisions": pagify("provisions", provisionsPage), - "/virtualParameters": pagify("virtualParameters", virtualParametersPage) + "/virtualParameters": pagify("virtualParameters", virtualParametersPage), + "/files": pagify("files", filesPage) }); diff --git a/client/components/put-form.js b/client/components/put-form.js index 5d70b9f..0a904c1 100644 --- a/client/components/put-form.js +++ b/client/components/put-form.js @@ -7,7 +7,8 @@ import "codemirror/mode/javascript/javascript"; const singular = { presets: "preset", provisions: "provision", - virtualParameters: "virtual parameter" + virtualParameters: "virtual parameter", + files: "file" }; function createField(current, attr, focus) { @@ -52,6 +53,20 @@ function createField(current, attr, focus) { if (focus) editor.focus(); } }); + } else if (attr.type === "file") { + return m("input", { + type: "file", + name: attr.id, + oncreate: focus + ? _vnode => { + _vnode.dom.focus(); + } + : null, + onchange: e => { + current.object[attr.id] = e.target.files; + e.redraw = false; + } + }); } return m("input", { @@ -102,7 +117,8 @@ const component = { ); } - let buttons = [m("button.primary", { type: "submit" }, "Save")]; + const submit = m("button.primary", { type: "submit" }, "Save"); + let buttons = [submit]; if (!current.isNew) buttons.push( @@ -129,9 +145,11 @@ const component = { "form", { onsubmit: e => { - e.target.disabled = true; - e.preventDefault(); e.redraw = false; + e.target.onsubmit = null; + e.preventDefault(); + for (let elem of e.target.elements) elem.disabled = true; + submit.dom.textContent = "Loading ..."; actionHandler("save", current.object); } }, diff --git a/client/files-page.js b/client/files-page.js new file mode 100644 index 0000000..02f0424 --- /dev/null +++ b/client/files-page.js @@ -0,0 +1,370 @@ +"use strict"; + +import m from "mithril"; + +import config from "./config"; +import filterComponent from "./filter-component"; +import * as store from "./store"; +import * as expression from "../common/expression"; +import * as notifications from "./notifications"; +import memoize from "../common/memoize"; +import putForm from "./components/put-form"; +import * as overlay from "./overlay"; +import * as smartQuery from "./smart-query"; +import * as expressionParser from "../common/expression-parser"; + +const PAGE_SIZE = config.ui.pageSize || 10; + +const memoizedParse = memoize(expression.parse); +const memoizedJsonParse = memoize(JSON.parse); + +const attributes = [ + { id: "_id", label: "Name" }, + { + id: "metadata.fileType", + label: "Type", + type: "combo", + options: [ + "1 Firmware Upgrade Image", + "2 Web Content", + "3 Vendor Configuration File", + "4 Tone File", + "5 Ringer File" + ] + }, + { id: "metadata.oui", label: "OUI" }, + { id: "metadata.productClass", label: "Product Class" }, + { id: "metadata.version", label: "Version" } +]; + +let formData = { + resource: "files", + attributes: attributes + .slice(1) // remove _id from new object form + .concat([{ id: "file", label: "File", type: "file" }]) +}; + +const unpackSmartQuery = memoize(query => { + return expressionParser.map(query, e => { + if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q") + return smartQuery.unpack("files", e[2], e[3]); + return e; + }); +}); + +function putActionHandler(action, object) { + return new Promise((resolve, reject) => { + if (action === "save") { + let file = object["file"] ? object["file"][0] : null; + delete object["file"]; + + if (!file) return reject(new Error("File not selected")); + + let id = file.name; + + store + .resourceExists("files", id) + .then(exists => { + if (exists) { + store.fulfill(0, Date.now()); + return reject(new Error("File already exists")); + } + let headers = Object.assign( + { + "Content-Type": "application/octet-stream", + Accept: "application/octet-stream" + }, + object + ); + + m.request({ + method: "PUT", + headers: headers, + url: `/api/files/${encodeURIComponent(id)}`, + serialize: body => { + return body; // Identity function to prevent JSON.parse on blob data + }, + data: file + }) + .then(() => { + notifications.push( + "success", + `File ${exists ? "updated" : "created"}` + ); + store.fulfill(0, Date.now()); + resolve(); + }) + .catch(reject); + }) + .catch(reject); + } else { + reject(new Error("Undefined action")); + } + }); +} + +const getDownloadUrl = memoize(filter => { + let cols = {}; + for (let attr of attributes) cols[attr.label] = attr.id; + return `/api/files.csv?${m.buildQueryString({ + filter: filter, + columns: JSON.stringify(cols) + })}`; +}); + +function init(args) { + if (!window.authorizer.hasAccess("files", 2)) + return Promise.reject( + new Error("You are not authorized to view this page") + ); + + const sort = args.sort; + const filter = args.filter; + return Promise.resolve({ filter, sort }); +} + +function renderTable( + filesResponse, + total, + selected, + showMoreCallback, + downloadUrl, + sort, + onSortChange +) { + const files = filesResponse.value; + const selectAll = m("input", { + type: "checkbox", + checked: files.length && selected.size === files.length, + onchange: e => { + for (let file of files) + if (e.target.checked) selected.add(file["_id"]); + else selected.delete(file["_id"]); + }, + disabled: !total + }); + + const labels = [m("th", selectAll)]; + for (let attr of attributes) { + let label = attr.label; + + let direction = 1; + + let symbol = "\u21f3"; + if (sort[attr.id] > 0) symbol = "\u2b07"; + else if (sort[attr.id] < 0) symbol = "\u2b06"; + + let sortable = m( + "button", + { + onclick: () => { + if (sort[attr.id] > 0) direction *= -1; + return onSortChange(JSON.stringify({ [attr.id]: direction })); + } + }, + symbol + ); + + labels.push(m("th", [label, sortable])); + } + + let rows = []; + for (let file of files) { + let checkbox = m("input", { + type: "checkbox", + checked: selected.has(file["_id"]), + onchange: e => { + if (e.target.checked) selected.add(file["_id"]); + else selected.delete(file["_id"]); + }, + onclick: e => { + e.stopPropagation(); + e.redraw = false; + } + }); + + let tds = [m("td", checkbox)]; + for (let attr of attributes) + if (attr.id == "script") + tds.push(m("td", { title: file[attr.id] }, file[attr.id])); + else tds.push(m("td", file[attr.id])); + + rows.push( + m( + "tr", + { + onclick: e => { + if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { + e.redraw = false; + return; + } + + if (!selected.delete(file["_id"])) selected.add(file["_id"]); + } + }, + tds + ) + ); + } + + if (!rows.length) + rows.push(m("tr.empty", m("td", { colspan: labels.length }, "No files"))); + + let footerElements = []; + if (total != null) footerElements.push(`${files.length}/${total}`); + else footerElements.push(`${files.length}`); + + footerElements.push( + m( + "button", + { + title: "Show more files", + onclick: showMoreCallback, + disabled: files.length >= total || !filesResponse.fulfilled + }, + "More" + ) + ); + + if (downloadUrl) + footerElements.push( + m("a.download-csv", { href: downloadUrl, download: "" }, "Download") + ); + + let tfoot = m( + "tfoot", + m("tr", m("td", { colspan: labels.length }, footerElements)) + ); + + const buttons = [ + m( + "button.primary", + { + title: "Delete selected files", + disabled: !selected.size, + onclick: e => { + e.redraw = false; + e.target.disabled = true; + Promise.all( + Array.from(selected).map(id => store.deleteResource("files", id)) + ) + .then(res => { + notifications.push("success", `${res.length} files deleted`); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } + }, + "Delete" + ) + ]; + + if (window.authorizer.hasAccess("files", 3)) + buttons.push( + m( + "button.primary", + { + title: "Create new file", + onclick: () => { + let cb = () => { + return m( + putForm, + Object.assign( + { + actionHandler: (action, object) => { + putActionHandler(action, object) + .then(() => { + overlay.close(cb); + }) + .catch(err => { + notifications.push("error", err.message); + overlay.close(cb); + m.redraw(); + }); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "New" + ) + ); + + return [ + m( + "table.table.highlight", + m("thead", m("tr", labels)), + m("tbody", rows), + tfoot + ), + m("div.actions-bar", buttons) + ]; +} + +const component = { + view: vnode => { + document.title = "Files - GenieACS"; + + function showMore() { + vnode.state.showCount = (vnode.state.showCount || PAGE_SIZE) + PAGE_SIZE; + m.redraw(); + } + + function onFilterChanged(filter) { + let ops = { filter }; + if (vnode.attrs.sort) ops.sort = vnode.attrs.sort; + m.route.set("/files", ops); + } + + function onSortChange(sort) { + let ops = { sort }; + if (vnode.attrs.filter) ops.filter = vnode.attrs.filter; + m.route.set("/files", ops); + } + + const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; + let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; + filter = unpackSmartQuery(filter); + + let files = store.fetch("files", filter, { + limit: vnode.state.showCount || PAGE_SIZE, + sort: sort + }); + + let count = store.count("files", filter); + + let selected = new Set(); + if (vnode.state.selected) + for (let file of files.value) + if (vnode.state.selected.has(file["_id"])) selected.add(file["_id"]); + vnode.state.selected = selected; + + const downloadUrl = getDownloadUrl(vnode.attrs.filter); + + return [ + m("h1", "Listing files"), + m(filterComponent, { + resource: "files", + filter: vnode.attrs.filter, + onChange: onFilterChanged + }), + renderTable( + files, + count.value, + selected, + showMore, + downloadUrl, + sort, + onSortChange + ) + ]; + } +}; + +export { init, component }; diff --git a/client/menu.js b/client/menu.js index c77b414..cf7ef8a 100644 --- a/client/menu.js +++ b/client/menu.js @@ -61,6 +61,15 @@ const menu = { ) ); + if (window.authorizer.hasAccess("files", 2)) + tabs.push( + m( + "li", + { class: active["files"] }, + m("a", { href: "#!/files" }, "Files") + ) + ); + return m("nav", m("ul", tabs)); } }; diff --git a/client/smart-query.js b/client/smart-query.js index ebabea6..256e6ed 100644 --- a/client/smart-query.js +++ b/client/smart-query.js @@ -22,6 +22,16 @@ let resources = { }, virtualParameters: { ID: { parameter: ["PARAM", "_id"], type: "string" } + }, + files: { + ID: { parameter: ["PARAM", "_id"], type: "string" }, + Type: { parameter: ["PARAM", "metadata.fileType"], type: "string" }, + OUI: { parameter: ["PARAM", "metadata.oui"], type: "string" }, + "Product class": { + parameter: ["PARAM", "metadata.productClass"], + type: "string" + }, + Version: { parameter: ["PARAM", "metadata.version"], type: "string" } } }; diff --git a/client/store.js b/client/store.js index 052a0db..dab64a2 100644 --- a/client/store.js +++ b/client/store.js @@ -29,7 +29,8 @@ for (let r of [ "files", "presets", "provisions", - "virtualParameters" + "virtualParameters", + "files" ]) resources[r] = { objects: new Map(), diff --git a/config.json b/config.json index 079c7be..7d09554 100644 --- a/config.json +++ b/config.json @@ -55,7 +55,10 @@ } }, "files": { - "access": 3 + "access": 3, + "validate": { + "pass": true + } } } ], diff --git a/server/api-functions.js b/server/api-functions.js index b8c859d..3f46c23 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -335,9 +335,28 @@ function putResource(resource, id, data) { }); } +function putFile(id, metadata, fileStream) { + return new Promise((resolve, reject) => { + let options = url.parse( + `${config.server.nbi}files/${encodeURIComponent(id)}` + ); + + options.method = "PUT"; + options.headers = Object.assign({}, metadata); + let _http = options.protocol === "https:" ? https : http; + let req = _http.request(options, res => { + res.resume(); + if (res.statusCode === 201) return resolve(); + else return reject(new Error(`Unexpected status code ${res.statusCode}`)); + }); + fileStream.pipe(req); + }); +} + exports.postTasks = postTasks; exports.deleteResource = deleteResource; exports.putResource = putResource; exports.updateTags = updateTags; exports.filterToMongoQuery = filterToMongoQuery; exports.ping = ping; +exports.putFile = putFile; diff --git a/server/api.js b/server/api.js index 0d8c205..71c36b7 100644 --- a/server/api.js +++ b/server/api.js @@ -351,6 +351,61 @@ for (let [resource, flags] of Object.entries(resources)) { }); } +router.put("/files/:id", async (ctx, next) => { + const resource = "files"; + const id = ctx.params.id; + + const log = { + message: `Upload ${resource}`, + context: ctx, + id: id + }; + + const authorizer = ctx.state.authorizer; + if (!authorizer.hasAccess(resource, 3)) { + logUnauthorizedWarning(log); + return next(); + } + + let metadata = { + fileType: ctx.request.headers["metadata.filetype"] || "", + oui: ctx.request.headers["metadata.oui"] || "", + productClass: ctx.request.headers["metadata.productclass"] || "", + version: ctx.request.headers["metadata.version"] || "" + }; + + const validate = authorizer.getValidator(resource, metadata); + if (!validate("put")) { + logUnauthorizedWarning(log); + return (ctx.status = 403); + } + + let err = await apiFunctions.putFile(id, metadata, ctx.req); + + if (err) { + log.message += " failed"; + logger.accessWarn(log); + ctx.body = err; + return (ctx.status = 400); + } + + log.metadata = metadata; + logger.accessInfo(log); + + db.putAudit({ + username: ctx.state.user.username, + action: "put", + objectType: resource, + objectId: id + }).catch(err => { + setTimeout(() => { + throw err; + }, 0); + }); + + ctx.body = ""; +}); + router.post("/devices/:id/tasks", async (ctx, next) => { const log = { message: "Commit tasks", diff --git a/server/db.js b/server/db.js index d3b5b90..0d1dfaa 100644 --- a/server/db.js +++ b/server/db.js @@ -17,7 +17,9 @@ const RESOURCE_DB = { files: "genieacs" }; -const RESOURCE_COLLECTION = {}; +const RESOURCE_COLLECTION = { + files: "fs.files" +}; function getClient() { if (!_clientPromise) @@ -91,6 +93,8 @@ function query(resource, filter, options, callback) { docs = docs.map(d => mongodbFunctions.flattenTask(d)); else if (resource === "presets") docs = docs.map(d => mongodbFunctions.flattenPreset(d)); + else if (resource === "files") + docs = docs.map(d => mongodbFunctions.flattenFile(d)); return resolve(docs); }); else @@ -104,6 +108,8 @@ function query(resource, filter, options, callback) { doc = mongodbFunctions.flattenTask(doc); else if (resource === "presets") doc = mongodbFunctions.flattenPreset(doc); + else if (resource === "files") + doc = mongodbFunctions.flattenFile(doc); callback(doc); }, err => { diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index c6643a1..9cf0251 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -457,6 +457,18 @@ function flattenPreset(preset) { return p; } +function flattenFile(file) { + let f = {}; + f["_id"] = file["_id"]; + if (file.metadata) { + f["metadata.fileType"] = file.metadata.fileType || ""; + f["metadata.oui"] = file.metadata.oui || ""; + f["metadata.productClass"] = file.metadata.productClass || ""; + f["metadata.version"] = file.metadata.version || ""; + } + return f; +} + function preProcessPreset(data) { let preset = Object.assign({}, data); preset.precondition = preset.precondition @@ -512,6 +524,7 @@ exports.flattenDevice = flattenDevice; exports.flattenFault = flattenFault; exports.flattenTask = flattenTask; exports.flattenPreset = flattenPreset; +exports.flattenFile = flattenFile; exports.preProcessPreset = preProcessPreset; exports.preProcessProvision = preProcessProvision; exports.preProcessVirtualParameters = preProcessVirtualParameters; diff --git a/server/server.js b/server/server.js index 2e71034..012230c 100644 --- a/server/server.js +++ b/server/server.js @@ -128,6 +128,13 @@ router.post("/logout", async ctx => { }); }); +koa.use(async (ctx, next) => { + if (ctx.request.type === "application/octet-stream") + ctx.disableBodyParser = true; + + return next(); +}); + koa.use(koaBodyParser()); router.use("/api", api.routes(), api.allowedMethods());