diff --git a/eslint.config.mjs b/eslint.config.mjs index c52a1664..96511cc8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,32 +1,24 @@ -// Import Node.js Dependencies -import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { ESLintConfig, globals } from "@openally/config.eslint"; -// Import Third-party Dependencies -import { FlatCompat } from "@eslint/eslintrc"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname -}); - -export default [{ - ignores: ["**/node_modules/", "**/tmp/", "**/dist/", "**/coverage/", "**/fixtures/"] -}, ...compat.extends("@nodesecure/eslint-config"), { - languageOptions: { - sourceType: "module", - - parserOptions: { - requireConfigFile: false +export default [ + ...ESLintConfig, + { + rules: { + "func-style": "off", + "no-invalid-this": "off", + "no-inner-declarations": "off", + "no-case-declarations": "off", + // TODO: enable this rule when migrating to @topcli/cmder + "default-param-last": "off" + }, + languageOptions: { + sourceType: "module", + globals: { + ...globals.browser + } } }, - - rules: { - "func-style": "off", - "no-invalid-this": "off", - "no-inner-declarations": "off", - "no-case-declarations": "off" + { + ignores: ["**/node_modules/", "**/tmp/", "**/dist/", "**/coverage/", "**/fixtures/"] } -}]; +]; diff --git a/i18n/english.js b/i18n/english.js index 95ecfcf0..3085c494 100644 --- a/i18n/english.js +++ b/i18n/english.js @@ -1,4 +1,5 @@ -/* eslint-disable max-len */ +/* eslint-disable @stylistic/max-len */ + // Import Third-party Dependencies import { taggedString as tS } from "@nodesecure/i18n"; @@ -188,7 +189,11 @@ const ui = { "Node.js core modules": "Node.js core modules", "Available licenses": "Available licenses", "Available flags": "Available flags", - default: "Search options" + default: "Search options", + packagesCache: "Packages available in the cache", + noPackageFound: "No package found", + packageLengthErr: "Package name must be between 2 and 64 characters.", + registryPlaceholder: "Search packages" }, legend: { default: "The package is fine.", diff --git a/i18n/french.js b/i18n/french.js index e1fbf517..7ab82e02 100644 --- a/i18n/french.js +++ b/i18n/french.js @@ -1,4 +1,5 @@ -/* eslint-disable max-len */ +/* eslint-disable @stylistic/max-len */ + // Import Third-party Dependencies import { taggedString as tS } from "@nodesecure/i18n"; @@ -188,7 +189,11 @@ const ui = { "Node.js core modules": "Modules de base de Node.js", "Available licenses": "Licences disponibles", "Available flags": "Drapeaux disponibles", - default: "Options de recherche" + default: "Options de recherche", + packagesCache: "Packages disponibles dans le cache", + noPackageFound: "Aucun package trouvé", + packageLengthErr: "Le nom du package doit être compris entre 2 et 64 caractères.", + registryPlaceholder: "Recherche de packages" }, legend: { default: "Rien à signaler.", diff --git a/package.json b/package.json index 596b2550..12e479a8 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,14 @@ "node": ">=18" }, "scripts": { - "eslint": "eslint bin src test workspaces", - "eslint-fix": "npm run eslint -- --fix", + "lint": "eslint bin src test workspaces", + "lint-fix": "npm run lint -- --fix", "prepublishOnly": "rimraf ./dist && npm run build && pkg-ok", "build": "node ./esbuild.config.js", - "test": "npm run test-only && npm run eslint", - "test-only": "glob -c \"node --loader=esmock --no-warnings --test\" \"test/**/*.test.js\"", - "coverage": "c8 --reporter=lcov npm run test" + "test": "npm run test-only && npm run lint", + "test-only": "glob -c \"node --loader=esmock --no-warnings --test-concurrency 1 --test\" \"test/**/*.test.js\"", + "coverage": "c8 --reporter=lcov npm run test", + "clear:cache": "node ./scripts/clear-cache.js" }, "files": [ "bin", @@ -62,14 +63,13 @@ "homepage": "https://github.com/NodeSecure/cli#readme", "devDependencies": { "@myunisoft/httpie": "^5.0.0", - "@nodesecure/eslint-config": "2.0.0-beta.0", "@nodesecure/size-satisfies": "^1.1.0", "@nodesecure/vis-network": "^1.4.0", + "@openally/config.eslint": "^1.1.0", "@types/node": "^22.2.0", "c8": "^10.1.2", "cross-env": "^7.0.3", "esbuild": "^0.23.0", - "eslint": "^9.8.0", "esmock": "^2.6.7", "glob": "^11.0.0", "http-server": "^14.1.1", @@ -104,10 +104,13 @@ "kleur": "^4.1.5", "ms": "^2.1.3", "open": "^10.1.0", + "pino": "^9.3.2", + "pino-pretty": "^11.2.2", "polka": "^0.5.2", "sade": "^1.8.1", "semver": "^7.6.3", "sirv": "^2.0.4", + "ws": "^8.18.0", "zup": "0.0.2" } } diff --git a/public/common/utils.js b/public/common/utils.js index bacfcdbc..da597cf1 100644 --- a/public/common/utils.js +++ b/public/common/utils.js @@ -250,3 +250,15 @@ export function currentLang() { return detectedLang in window.i18n ? detectedLang : "english"; } + +export function debounce(callback, delay) { + let timer; + + // eslint-disable-next-line func-names + return function() { + clearTimeout(timer); + timer = setTimeout(() => { + callback(); + }, delay); + }; +} diff --git a/public/components/navigation/navigation.css b/public/components/navigation/navigation.css index f28fc084..b7213e10 100644 --- a/public/components/navigation/navigation.css +++ b/public/components/navigation/navigation.css @@ -1,4 +1,4 @@ -nav { +nav#aside { width: 70px; flex-shrink: 0; background: var(--primary); @@ -8,60 +8,66 @@ nav { flex-direction: column; z-index: 40; } - nav > .nsecure-logo { - margin-top: 20px; - } - nav > ul { - width: inherit; - display: flex; - margin-top: 10px; - flex-direction: column; - flex-grow: 1; - margin-bottom: 20px; - } +nav#aside>.nsecure-logo { + margin-top: 20px; +} - nav > ul li { - height: 70px; - display: flex; - position: relative; - justify-content: center; - align-items: center; - } - nav > ul li+li { - margin-top: 10px; - } +nav#aside>ul { + width: inherit; + display: flex; + margin-top: 10px; + flex-direction: column; + flex-grow: 1; + margin-bottom: 20px; +} - nav > ul li:not(.active):hover { - cursor: pointer; - background: rgba(50, 200, 255, 0.085); - } +nav#aside>ul li { + height: 70px; + display: flex; + position: relative; + justify-content: center; + align-items: center; +} + +nav#aside>ul li+li { + margin-top: 10px; +} - nav > ul li.active:before { - background: var(--secondary); - position: absolute; - left: 0; - top: 17.5px; - height: 35px; - width: 4px; - border-radius: 0 4px 4px 0; - content: ""; - } +nav#aside>ul li:not(.active):hover { + cursor: pointer; + background: rgba(50, 200, 255, 0.085); +} + +nav#aside>ul li.active:before { + background: var(--secondary); + position: absolute; + left: 0; + top: 17.5px; + height: 35px; + width: 4px; + border-radius: 0 4px 4px 0; + content: ""; +} + +nav#aside>ul li>i { + font-size: 24px; +} + +nav#aside>ul li.active, +nav#aside>ul li.active span { + color: var(--secondary); +} + +nav#aside>ul li>span { + position: absolute; + left: 10px; + bottom: 5px; + font-size: 12px; + color: #FFF; + font-weight: bold; +} - nav > ul li > i { - font-size: 24px; - } - nav > ul li.active, nav > ul li.active span { - color: var(--secondary); - } - nav > ul li > span { - position: absolute; - left: 10px; - bottom: 5px; - font-size: 12px; - color: #FFF; - font-weight: bold; - } -.bottom-nav { +nav#aside>ul li.bottom-nav { margin-top: auto; } diff --git a/public/components/navigation/navigation.js b/public/components/navigation/navigation.js index 53696e47..4dead174 100644 --- a/public/components/navigation/navigation.js +++ b/public/components/navigation/navigation.js @@ -5,6 +5,7 @@ import { PackageInfo } from "../package/package.js"; const kAvailableView = new Set([ "network--view", "home--view", + "search--view", "settings--view" ]); @@ -50,6 +51,10 @@ export class ViewNavigation { this.onNavigationSelected(this.menus.get("settings--view")); break; } + case hotkeys.search: { + this.onNavigationSelected(this.menus.get("search--view")); + break; + } } }); } @@ -68,6 +73,11 @@ export class ViewNavigation { selectedNav.classList.add("active"); this.setAnchor(menuName); + const searchbar = document.getElementById("searchbar"); + if (searchbar) { + searchbar.style.display = menuName === "network--view" ? "flex" : "none"; + } + this.activeMenu = selectedNav; } diff --git a/public/components/searchbar/searchbar.css b/public/components/searchbar/searchbar.css index b40d9d20..cddcc483 100644 --- a/public/components/searchbar/searchbar.css +++ b/public/components/searchbar/searchbar.css @@ -1,14 +1,13 @@ #searchbar { - background: #263c46; + background: linear-gradient(to bottom, #37474f 0%, #263238 100%); display: flex; - height: 40px; - position: absolute; - z-index: 40; - right: 40px; - top: 0; + height: inherit; box-sizing: border-box; - border-radius: 0 0 4px 4px; - box-shadow: -6px 4px 0px #3a00ffbd; + border-left: 2px solid #0f041a; +} + +#searchbar>* { + transform: skewX(20deg); } #searchbar>div.search-items { @@ -53,9 +52,9 @@ background: none; border: none; outline: none; - padding: 0 10px; color: #FFF; font-family: "mononoki"; + margin-bottom: 2px; } #searchbar>input::placeholder { @@ -72,12 +71,12 @@ } div.search-result-background { - width: 100%; position: absolute; - top: 35px; - right: 0; display: none; - margin-top: 25px; + margin-top: 35px; + right: -130px; + min-width: 360px; + max-width: 400px; padding: 10px !important; background: #263238; box-shadow: 1px 1px 10px rgba(20, 20, 20, 0.4); @@ -191,9 +190,132 @@ div.search-result-pannel .package>b { } div.search-result-pannel .package.hide { - display: none; + display: none !important; } div.search-result-pannel .package+.package { margin-top: 5px; } + +#search-nav { + z-index: 30; + display: flex; + justify-content: center; + align-items: center; + position: absolute; + height: 30px; + left: 50px; + padding-left: 20px; + max-width: calc(100vw - 70px); + box-sizing: border-box; + background: var(--primary); + transform: skewX(-20deg); + box-shadow: 2px 1px 10px #26107f7a; +} + +#search-nav .search-result-pannel .package { + height: 30px; + color: rgb(229, 229, 229); + display: flex; + align-items: center; + padding: 0 10px; + font-family: "Roboto"; +} + +#search-nav .packages { + height: inherit; + display: flex; + max-width: calc(100vw - 70px - 264px); + background: var(--primary); +} + +#search-nav .packages>.package { + height: inherit; + color: rgb(229, 229, 229); + font-family: "mononoki"; + display: flex; + align-items: center; + background: linear-gradient(to right, rgba(55,34,175,1) 0%,rgba(87,74,173,1) 50%,rgb(59, 110, 205) 100%); + padding: 0 10px; + border-right: 2px solid #0f041a; + text-shadow: 1px 1px 10px #000; + color: #def7ff; +} + +#search-nav .packages>.package>* { + transform: skewX(20deg); +} + +#search-nav .packages>.package:first-child { + padding-left: 10px; +} + +#search-nav .packages>.package:not(.active):hover { + background: linear-gradient(to right, rgba(55, 34, 175, 1) 1%, rgb(68, 121, 218) 100%); + color: #defff9; + cursor: pointer; +} + +#search-nav .packages>.package.active { + background: linear-gradient(to right, rgba(55,34,175,1) 0%,rgba(87,74,173,1) 50%,rgb(59, 110, 205) 100%); +} + +#search-nav .packages>.package.active>b { + background: var(--secondary); +} + +#search-nav .packages>.package.active>.remove { + display: block; +} + +#search-nav .packages>.package>b{ + font-weight: bold; + font-size: 12px; + margin-left: 5px; + background: var(--secondary-darker); + padding: 3px 5px; + border-radius: 2px; + font-family: "Roboto"; + letter-spacing: 1px; +} + +#search-nav .add { + height: inherit; + color: white; + font-size: 20px; + border: none; + background: var(--secondary-darker); + cursor: pointer; + padding: 0 7px; + transition: 0.2s all ease; + color: #def7ff; +} + +#search-nav .add:hover { + background: var(--secondary); + cursor: pointer; +} + +#search-nav .add>i { + transform: skewX(20deg); +} + +#search-nav button.remove { + display: none; + border: none; + position: relative; + cursor: pointer; + color: #fff5dc; + background: #ff3434e2; + margin-left: 10px; + border-radius: 50%; + line-height: 16px; + text-shadow: 1px 1px 10px #000; + font-weight: bold; + width: 20px; +} + +#search-nav button.remove:hover { + cursor: pointer; + background: #ff5353e2; +} diff --git a/public/components/searchbar/searchbar.js b/public/components/searchbar/searchbar.js index 6f485aab..bf5f8db4 100644 --- a/public/components/searchbar/searchbar.js +++ b/public/components/searchbar/searchbar.js @@ -20,12 +20,8 @@ const kHelpersTemplateName = { const fragment = document.createDocumentFragment(); const items = new Set(); - for (const { license } of linker.values()) { - if (typeof license === "string") { - items.add("Unknown"); - continue; - } - license.uniqueLicenseIds.forEach((ext) => items.add(ext)); + for (const { uniqueLicenseIds = [] } of linker.values()) { + uniqueLicenseIds.forEach((ext) => items.add(ext)); } [...items].forEach((value) => fragment.appendChild(createLineElement(value))); @@ -56,7 +52,10 @@ const kHelpersTemplateName = { const fragment = document.createDocumentFragment(); const items = new Set(); for (const { author } of linker.values()) { - items.add(typeof author === "string" ? author : author.name); + if (author === null) { + continue; + } + items.add(author.name); } [...items].forEach((value) => fragment.appendChild(createLineElement(value))); @@ -310,6 +309,10 @@ export class SearchBar { self.focusNodeById(this.getAttribute("data-value")); }); } + + if (window.navigation.getAnchor() !== "network--view") { + this.container.style.display = "none"; + } } addNewSearchText(filterName, searchedValue) { @@ -378,7 +381,7 @@ export class SearchBar { const titleText = window.i18n[currentLang()].search[ Reflect.has(kHelpersTitleName, filterName) ? kHelpersTitleName[filterName] : "default" ]; - // eslint-disable-next-line max-len + // eslint-disable-next-line @stylistic/max-len this.helper.innerHTML = `

${titleText}

`; this.helper.appendChild(clone); } @@ -434,8 +437,9 @@ export class SearchBar { break; } case "license": { - const licences = typeof opt.license === "string" ? ["Unknown"] : [...new Set(opt.license.uniqueLicenseIds)]; - const hasMatchingLicense = licences.some((value) => new RegExp(inputValue, "gi").test(value)); + const hasMatchingLicense = opt.uniqueLicenseIds.some( + (value) => new RegExp(inputValue, "gi").test(value) + ); if (hasMatchingLicense) { matchingIds.add(String(id)); } @@ -470,8 +474,10 @@ export class SearchBar { case "author": { const authorRegex = new RegExp(inputValue, "gi"); - if ((typeof opt.author === "string" && authorRegex.test(opt.author)) || - (opt.author.name && authorRegex.test(opt.author.name))) { + if ( + (typeof opt.author === "string" && authorRegex.test(opt.author)) || + (opt.author !== null && "name" in opt.author && authorRegex.test(opt.author.name)) + ) { matchingIds.add(String(id)); } break; diff --git a/public/components/views/home/home.js b/public/components/views/home/home.js index b755a45d..30cdb9f8 100644 --- a/public/components/views/home/home.js +++ b/public/components/views/home/home.js @@ -40,6 +40,10 @@ export class HomeView { this.nsn = nsn; this.lang = utils.currentLang(); + const homeViewHTMLElement = document.getElementById("home--view"); + homeViewHTMLElement.innerHTML = ""; + homeViewHTMLElement.appendChild(this.render()); + this.generateScorecard(); this.generateHeader(); this.generateOverview(); @@ -51,6 +55,19 @@ export class HomeView { this.handleReport(); } + render() { + console.log("[HOME] cloning new template"); + const template = document.getElementById("home-view-content"); + if (!template) { + throw new Error("Unable to find HTML template with ID 'home-view-content'"); + } + + /** @type {HTMLTemplateElement} */ + const clone = document.importNode(template.content, true); + + return clone; + } + generateScorecard() { const { name, version } = this.secureDataSet.linker.get(0); const pkg = this.secureDataSet.data.dependencies[name]; diff --git a/public/components/views/search/search.css b/public/components/views/search/search.css new file mode 100644 index 00000000..aefdbc68 --- /dev/null +++ b/public/components/views/search/search.css @@ -0,0 +1,259 @@ +#search--view { + display: flex; + flex-direction: column; + align-items: center; + margin: auto; +} + +#search--view, +#search--view * { + box-sizing: border-box; + outline: none; +} + +#search--view .container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + max-width: 600px; + min-height: 150px; + margin: 135px auto; +} + +#search--view form { + width: 100%; +} + +#search--view form input { + padding: 11px 6px; + border: none; + font-size: 16px; + width: 100%; + color: rgb(78, 76, 76); + outline: none; +} + +.result-not-found { + color: rgb(78, 76, 76); + text-align: center; +} + +.hint { + color: rgb(239 126 126); + text-align: center; + margin-top: 10px; + font-style: italic; +} + +#search--view form input::placeholder { + color: rgb(125, 125, 125); + font-style: italic; +} + +.result-container { + max-height: calc(100vh - 340px); + overflow-y: auto; + margin-top: 10px; + border-radius: 4px; + width: 100%; +} + +.result-container::-webkit-scrollbar { + width: 0px !important; +} + +.result { + display: flex; + align-items: flex-start; + flex-direction: row; + background: white; + color: var(--primary); + padding-left: 5px; +} + +.result.exact { + background: #f4fff2 !important; +} + +.result:nth-child(even) { + box-shadow: 1px 1px 10px #33333314 inset; +} + +.result:nth-child(odd) { + background: #fcfcfa; +} + +.result span { + margin-top: 7px; +} + +.result:hover span { + opacity: 0.8; + cursor: pointer; + text-decoration: underline; +} + +.result:not(:last-child) { + border-bottom: 2px solid #f7f7f7; +} + +.result span, +.result select { + font-weight: bold; + font-size: 17px; + width: 100%; + cursor: pointer; + border: none; +} + +.result select { + max-width: 70px; + margin-right: 5px; + margin-top: 5px; + background: #edf1fb; +} + +.form-group { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + margin-top: 20px; + width: 100%; + margin: auto; + padding: 10px; + border-radius: 4px; + background: linear-gradient(135deg, rgba(242, 245, 246, 1) 0%, rgba(227, 234, 237, 1) 37%, rgba(234, 238, 239, 1) 100%); + box-shadow: 2px 2px 20px #3722af1f; + border: 1px solid #FFF; + box-sizing: border-box; + position: relative; +} + +.form-group>input, +.form-group>input::placeholder, +.form-group>input:-webkit-autofill { + background-color: transparent; + font-family: "mononoki" !important; + color: var(--primary-lighter) !important; + font-style: normal !important; +} + +#search--view .icon-search { + filter: invert(64%) sepia(100%) saturate(2094%) hue-rotate(241deg) brightness(67%) contrast(114%); + margin-right: 10px; + font-size: 20px; +} + +.scan-info { + height: 30px; + color: #546884; + font-family: "mononoki"; + margin-top: 10px; +} + +.spinner { + width: 56px; + height: 56px; + border-radius: 50%; + border: 9px solid #dbdcef; + border-right-color: #474bff; + animation: spinner-d3wgkg 1s infinite linear; +} + +.spinner-small { + width: 32px; + height: 32px; + border-radius: 50%; + border: 4px solid #dbdcef; + border-right-color: #474bff; + animation: spinner-d3wgkg 1s infinite linear; + position: absolute; + right: 10px; + margin: 0 !important; +} + +@keyframes spinner-d3wgkg { + to { + transform: rotate(1turn); + } +} + +input:-webkit-autofill { + -webkit-background-clip: text; +} + +.search-spinner { + margin: 25px auto 0; +} + +.package-result { + display: flex; + flex-direction: row; + align-items: flex-start; + padding-left: 5px; + width: 100%; + border-radius: 4px; +} + +.package-result .description { + font-size: 14px; + color: rgb(78, 76, 76); + margin-top: 5px; + margin-bottom: 8px; + line-height: 20px; +} + +.package-result button.remove { + border: none; + position: relative; + cursor: pointer; + color: #fff5dc; + background: #ff3434e2; + margin-left: auto; + border-radius: 50%; + line-height: 16px; + text-shadow: 1px 1px 10px #000; + font-weight: bold; + width: 20px; +} + +.cache-packages { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-top: 20px; + margin-bottom: 20px; + width: 100%; + color: var(--primary); + font-weight: bold; + font-size: 17px; + border: 1px solid #54688424; + padding: 10px; + border-radius: 4px; +} + +.cache-packages h1 { + font-family: "mononoki"; + color: #546884; +} + +.cache-packages .package-result { + margin-top: 10px; + background: #54688419; + padding: 5px 10px; +} + +.cache-packages .package-result:has(span:hover) { + color: var(--secondary-darker); + background: #5468842a; + cursor: pointer; +} + +.spinner-option { + font-size: smaller; + text-align: center; +} diff --git a/public/components/views/search/search.js b/public/components/views/search/search.js new file mode 100644 index 00000000..e2ad6551 --- /dev/null +++ b/public/components/views/search/search.js @@ -0,0 +1,243 @@ +// Import Third-party Dependencies +import { getJSON, NodeSecureDataSet, NodeSecureNetwork } from "@nodesecure/vis-network"; + +// Import Internal Dependencies +import { currentLang, debounce, createDOMElement } from "../../../common/utils.js"; + +// CONSTANTS +const kMinPackageNameLength = 2; +const kMaxPackageNameLength = 64; + +export class SearchView { + /** + * @type {NodeSecureDataSet} + */ + secureDataSet; + /** + * @type {NodeSecureNetwork} + */ + nsn; + + /** + * @param {!NodeSecureDataSet} secureDataSet + * @param {!NodeSecureNetwork} nsn + */ + constructor( + secureDataSet, + nsn + ) { + this.secureDataSet = secureDataSet; + this.nsn = nsn; + + this.initialize(); + } + + initialize() { + this.searchContainer = document.querySelector("#search--view .container"); + this.searchForm = document.querySelector("#search--view form"); + const formGroup = this.searchForm.querySelector(".form-group"); + const input = this.searchForm.querySelector("input"); + const lang = currentLang(); + + input.addEventListener("input", debounce(async() => { + document.querySelector(".result-container")?.remove(); + this.searchForm.querySelector(".hint")?.remove(); + + const packageName = input.value; + if (packageName.length === 0) { + return; + } + else if (packageName.length < kMinPackageNameLength || packageName.length > kMaxPackageNameLength) { + const hintElement = document.createElement("div"); + hintElement.classList.add("hint"); + hintElement.textContent = window.i18n[lang].search.packageLengthErr; + this.searchForm.appendChild(hintElement); + + return; + } + + const loaderElement = createDOMElement("div", { + classList: ["spinner-small", "search-spinner"] + }); + formGroup.appendChild(loaderElement); + + const { result, count } = await getJSON(`/search/${encodeURIComponent(packageName)}`); + + this.searchForm.querySelector(".spinner-small").remove(); + + const divResultContainer = document.createElement("div"); + divResultContainer.classList.add("result-container"); + + if (count === 0) { + const divResultElement = document.createElement("div"); + divResultElement.classList.add("result-not-found"); + divResultElement.textContent = window.i18n[lang].search.noPackageFound; + divResultContainer.appendChild(divResultElement); + this.searchForm.appendChild(divResultContainer); + + return; + } + + for (const { name, version, description } of result) { + const divResultElement = document.createElement("div"); + divResultElement.classList.add("result"); + if (packageName === name) { + divResultElement.classList.add("exact"); + } + + const pkgElement = document.createElement("div"); + pkgElement.classList.add("package-result"); + const pkgSpanElement = document.createElement("span"); + pkgSpanElement.textContent = name; + pkgSpanElement.addEventListener("click", async() => { + const packageVersion = divResultElement.querySelector("select option:checked"); + await this.fetchPackage(name, packageVersion.value); + }, { once: true }); + pkgElement.appendChild(pkgSpanElement); + const pkgDescriptionElement = document.createElement("p"); + pkgDescriptionElement.textContent = description; + pkgDescriptionElement.classList.add("description"); + pkgElement.appendChild(pkgDescriptionElement); + divResultElement.appendChild(pkgElement); + + const selectElement = document.createElement("select"); + const optionElement = document.createElement("option"); + optionElement.value = version; + optionElement.textContent = version; + selectElement.appendChild(optionElement); + selectElement.addEventListener("click", async() => { + const spinnerOption = ""; + selectElement.insertAdjacentHTML("beforeend", spinnerOption); + + function spinnerOptionSpin() { + const spinnerOptionElement = selectElement.querySelector(".spinner-option"); + spinnerOptionElement.textContent += "."; + if (spinnerOptionElement.textContent.length > 3) { + spinnerOptionElement.textContent = "."; + } + } + + const spinIntervalId = setInterval(spinnerOptionSpin, 180); + + try { + const versions = await this.fetchPackageVersions(name); + + clearInterval(spinIntervalId); + + selectElement.querySelector(".spinner-option").remove(); + + for (const pkgVersion of versions) { + if (pkgVersion === version) { + continue; + } + const optionElement = document.createElement("option"); + optionElement.value = pkgVersion; + optionElement.textContent = pkgVersion; + selectElement.appendChild(optionElement); + } + } + catch { + clearInterval(spinIntervalId); + selectElement.querySelector(".spinner-option").remove(); + } + }, { once: true }); + divResultElement.appendChild(selectElement); + divResultContainer.appendChild(divResultElement); + } + this.searchForm.parentNode.insertBefore(divResultContainer, this.searchForm.nextSibling); + }, 500)); + + this.searchForm.addEventListener("submit", (event) => { + event.preventDefault(); + }); + + const cachePackagesElement = this.searchContainer.querySelector(".cache-packages"); + if (cachePackagesElement === null) { + return; + } + if (window.scannedPackageCache.length > 0) { + cachePackagesElement.classList.remove("hidden"); + const h1Element = document.createElement("h1"); + h1Element.textContent = window.i18n[lang].search.packagesCache; + cachePackagesElement.appendChild(h1Element); + + for (const pkg of window.scannedPackageCache) { + const pkgElement = document.createElement("div"); + pkgElement.classList.add("package-result"); + const pkgSpanElement = document.createElement("span"); + pkgSpanElement.textContent = pkg; + pkgSpanElement.addEventListener("click", () => { + window.socket.send(JSON.stringify({ action: "SEARCH", pkg })); + }, { once: true }); + const removeButton = createDOMElement("button", { + classList: ["remove"], + text: "x" + }); + removeButton.addEventListener("click", (event) => { + event.stopPropagation(); + window.socket.send(JSON.stringify({ action: "REMOVE", pkg })); + }, { once: true }); + pkgElement.append(pkgSpanElement, removeButton); + cachePackagesElement.appendChild(pkgElement); + } + } + else { + cachePackagesElement.classList.add("hidden"); + } + } + + async fetchPackage(packageName, version) { + const pkg = `${packageName}@${version}`; + + window.socket.send(JSON.stringify({ action: "SEARCH", pkg })); + } + + async fetchPackageVersions(packageName) { + const versions = await getJSON(`/search-versions/${encodeURIComponent(packageName)}`); + + return versions.reverse(); + } + + reset() { + const lang = currentLang(); + + const searchViewContainer = document.querySelector("#search--view .container"); + searchViewContainer.innerHTML = ""; + const form = document.createElement("form"); + const formGroup = document.createElement("div"); + formGroup.classList.add("form-group"); + const iconSearch = document.createElement("i"); + iconSearch.classList.add("icon-search"); + const input = document.createElement("input"); + input.type = "text"; + input.placeholder = window.i18n[lang].search.registryPlaceholder; + input.name = "package"; + input.id = "package"; + formGroup.appendChild(iconSearch); + formGroup.appendChild(input); + form.appendChild(formGroup); + searchViewContainer.appendChild(form); + + const cachePackagesElement = document.createElement("div"); + cachePackagesElement.classList.add("cache-packages", "hidden"); + searchViewContainer.appendChild(cachePackagesElement); + + this.initialize(); + } + + onScan(pkg) { + const searchViewForm = document.querySelector("#search--view form"); + searchViewForm.remove(); + const containerResult = document.querySelector("#search--view .result-container"); + containerResult?.remove(); + + const searchViewContainer = document.querySelector("#search--view .container"); + const scanInfo = document.createElement("div"); + scanInfo.classList.add("scan-info"); + scanInfo.textContent = `Scanning ${pkg}.`; + const spinner = document.createElement("div"); + spinner.classList.add("spinner"); + searchViewContainer.appendChild(scanInfo); + searchViewContainer.appendChild(spinner); + } +} diff --git a/public/components/views/settings/settings.js b/public/components/views/settings/settings.js index 9a9fe494..c809e13b 100644 --- a/public/components/views/settings/settings.js +++ b/public/components/views/settings/settings.js @@ -12,7 +12,8 @@ const kDefaultHotKeys = { network: "N", settings: "S", wiki: "W", - lock: "L" + lock: "L", + search: "F" }; const kShortcutInputTargetIds = new Set(Object.keys(kDefaultHotKeys)); diff --git a/public/core/search-nav.js b/public/core/search-nav.js new file mode 100644 index 00000000..58fe08a1 --- /dev/null +++ b/public/core/search-nav.js @@ -0,0 +1,124 @@ +// Import Internal Dependencies +import { createDOMElement, parseNpmSpec } from "../common/utils"; +import { SearchBar } from "../components/searchbar/searchbar"; + +export function initSearchNav(data, options) { + const { initFromZero = true, searchOptions = null } = options; + + const searchNavElement = document.getElementById("search-nav"); + if (!searchNavElement) { + throw new Error("Unable to found search navigation"); + } + + if (initFromZero) { + searchNavElement.innerHTML = ""; + searchNavElement.appendChild( + initPackagesNavigation(data) + ); + } + + if (searchOptions !== null) { + const { nsn, secureDataSet } = searchOptions; + + if (window.searchbar) { + console.log("[SEARCH-NAV] cleanup searchbar"); + document.getElementById("searchbar")?.remove(); + } + + const searchElement = document.getElementById("searchbar-content"); + searchNavElement.appendChild( + searchElement.content.cloneNode(true) + ); + + const searchBarPackagesContainer = document.getElementById("package-list"); + for (const info of secureDataSet.packages) { + const content = `

${info.flags} ${info.name}

${info.version}`; + searchBarPackagesContainer.insertAdjacentHTML( + "beforeend", + `
${content}
` + ); + } + window.searchbar = new SearchBar(nsn, secureDataSet.linker); + } +} + +function initPackagesNavigation(data) { + const fragment = document.createDocumentFragment(); + const packages = data.lru; + + const hasAtLeast2Packages = packages.length > 1; + const hasExactly2Packages = packages.length === 2; + const container = createDOMElement("div", { + classList: ["packages"] + }); + + for (const pkg of packages) { + const { name, version } = parseNpmSpec(pkg); + + const pkgElement = createDOMElement("div", { + classList: ["package"], + childs: [ + createDOMElement("p", { text: name }), + createDOMElement("b", { text: `v${version}` }) + ] + }); + pkgElement.dataset.name = pkg; + if (pkg === data.current) { + window.activePackage = pkg; + pkgElement.classList.add("active"); + } + pkgElement.addEventListener("click", () => { + if (window.activePackage !== pkg) { + window.socket.send(JSON.stringify({ pkg, action: "SEARCH" })); + } + }); + + if (hasAtLeast2Packages && pkg !== data.root) { + pkgElement.appendChild( + renderPackageRemoveButton(pkgElement.dataset.name, { hasExactly2Packages }) + ); + } + + container.appendChild(pkgElement); + } + + const plusButtonElement = createDOMElement("button", { + classList: ["add"], + childs: [ + createDOMElement("p", { text: "+" }) + ] + }); + plusButtonElement.addEventListener("click", () => { + window.navigation.setNavByName("search--view"); + }); + + fragment.append(container, plusButtonElement); + + return fragment; +} + +function renderPackageRemoveButton(packageName, options) { + const { + hasExactly2Packages + } = options; + + // we allow to remove a package when at least 2 packages are present + const removeButton = createDOMElement("button", { + classList: ["remove"], + text: "x" + }); + + removeButton.addEventListener("click", (event) => { + event.stopPropagation(); + window.socket.send(JSON.stringify({ action: "REMOVE", pkg: packageName })); + + if (hasExactly2Packages) { + document + .getElementById("search-nav") + .querySelectorAll(".package") + .forEach((element) => element.querySelector(".remove")?.remove()); + } + }, { once: true }); + + return removeButton; +} diff --git a/public/main.css b/public/main.css index 7e90e505..138021b3 100644 --- a/public/main.css +++ b/public/main.css @@ -16,6 +16,7 @@ @import url("./components/searchbar/searchbar.css"); @import url("./components/views/home/home.css"); @import url("./components/views/network/network.css"); +@import url("./components/views/search/search.css"); @import url("./components/views/settings/settings.css"); @import url("./components/wiki/wiki.css"); @import url("./components/gauge/gauge.css"); diff --git a/public/main.js b/public/main.js index 1e65c0cf..774cda3c 100644 --- a/public/main.js +++ b/public/main.js @@ -5,7 +5,6 @@ import { NodeSecureDataSet, NodeSecureNetwork } from "@nodesecure/vis-network"; import { PackageInfo } from "./components/package/package.js"; import { ViewNavigation } from "./components/navigation/navigation.js"; import { Wiki } from "./components/wiki/wiki.js"; -import { SearchBar } from "./components/searchbar/searchbar.js"; import { Popup } from "./components/popup/popup.js"; import { Locker } from "./components/locker/locker.js"; import { Legend } from "./components/legend/legend.js"; @@ -13,23 +12,90 @@ import { Legend } from "./components/legend/legend.js"; // Import Views Components import { Settings } from "./components/views/settings/settings.js"; import { HomeView } from "./components/views/home/home.js"; +import { SearchView } from "./components/views/search/search.js"; // Import Core Components import { NetworkNavigation } from "./core/network-navigation.js"; import { i18n } from "./core/i18n.js"; +import { initSearchNav } from "./core/search-nav.js"; // Import Utils import * as utils from "./common/utils.js"; +let secureDataSet; +let nsn; +let searchview; +let packageInfoOpened = false; + document.addEventListener("DOMContentLoaded", async() => { + window.scannedPackageCache = []; window.locker = null; window.popup = new Popup(); window.settings = await new Settings().fetchUserConfig(); window.i18n = await new i18n().fetch(); window.navigation = new ViewNavigation(); window.wiki = new Wiki(); - let packageInfoOpened = false; - const secureDataSet = new NodeSecureDataSet({ + + await init(); + onSettingsSaved(); + + window.socket = new WebSocket(`ws://${window.location.hostname}:1338`); + window.socket.addEventListener("message", async(event) => { + const data = JSON.parse(event.data); + console.log(`[WEBSOCKET] data status = '${data.status || "NONE"}'`); + + if (data.rootDependencyName) { + window.activePackage = data.rootDependencyName; + await init({ navigateToNetworkView: true }); + initSearchNav(data, { + initFromZero: false, + searchOptions: { + nsn, secureDataSet + } + }); + } + else if (data.status === "INIT" || data.status === "RELOAD") { + window.scannedPackageCache = data.older; + console.log( + "[INFO] Older packages are loaded!", + window.scannedPackageCache + ); + + initSearchNav(data, { + searchOptions: { + nsn, secureDataSet + } + }); + searchview.reset(); + const nsnActivePackage = secureDataSet.linker.get(0); + const nsnRootPackage = nsnActivePackage ? `${nsnActivePackage.name}@${nsnActivePackage.version}` : null; + if (data.status === "RELOAD" && nsnRootPackage !== null && nsnRootPackage !== window.activePackage) { + // it means we removed the previous active package, which is still active in network, so we need to re-init + await init(); + + // FIXME: initSearchNav is called twice, we need to fix this + initSearchNav(data, { + searchOptions: { + nsn, secureDataSet + } + }); + } + } + else if (data.status === "SCAN") { + searchview.onScan(data.pkg); + } + }); + + window.onbeforeunload = () => { + window.socket.onclose = () => void 0; + window.socket.close(); + }; +}); + +async function init(options = {}) { + const { navigateToNetworkView = false } = options; + + secureDataSet = new NodeSecureDataSet({ flagsToIgnore: window.settings.config.ignore.flags, warningsToIgnore: window.settings.config.ignore.warnings }); @@ -39,39 +105,90 @@ document.addEventListener("DOMContentLoaded", async() => { // Initialize vis Network NodeSecureNetwork.networkElementId = "dependency-graph"; - const nsn = new NodeSecureNetwork(secureDataSet, { i18n: window.i18n[utils.currentLang()] }); + nsn = new NodeSecureNetwork(secureDataSet, { i18n: window.i18n[utils.currentLang()] }); window.locker = new Locker(nsn); - const legend = new Legend({ show: window.settings.config.showFriendlyDependencies }); + window.legend = new Legend({ show: window.settings.config.showFriendlyDependencies }); new HomeView(secureDataSet, nsn); + searchview ??= new SearchView(secureDataSet, nsn); window.addEventListener("package-info-closed", () => { - networkNavigation.currentNodeParams = null; + window.networkNav.currentNodeParams = null; packageInfoOpened = false; }); nsn.network.on("click", updateShowInfoMenu); - function getNodeLevel(node) { - const rootNode = secureDataSet.linker.get(0); - if (node.id === rootNode.id) { - return 0; - } + const networkNavigation = new NetworkNavigation(secureDataSet, nsn); + window.networkNav = networkNavigation; - let level = 1; - let currentNode = node; - while (currentNode.usedBy[rootNode.name] === undefined) { - currentNode = secureDataSet.linker.get( - [...secureDataSet.linker].find(([_, { name }]) => Object.keys(currentNode.usedBy)[0] === name)[0] - ); - level++; + if (navigateToNetworkView) { + window.navigation.setNavByName("network--view"); + } + + // update search nav + const pkgs = document.querySelectorAll("#search-nav .packages > .package"); + for (const pkg of pkgs) { + if (pkg.dataset.name.startsWith(window.activePackage)) { + pkg.classList.add("active"); } + else { + pkg.classList.remove("active"); + } + } - return level; + PackageInfo.close(); + + console.log("[INFO] Node-Secure is ready!"); +} + +function getNodeLevel(node) { + const rootNode = secureDataSet.linker.get(0); + if (node.id === rootNode.id) { + return 0; } - const networkNavigation = new NetworkNavigation(secureDataSet, nsn); - window.networkNav = networkNavigation; + let level = 1; + let currentNode = node; + while (currentNode.usedBy[rootNode.name] === undefined) { + currentNode = secureDataSet.linker.get( + [...secureDataSet.linker].find(([_, { name }]) => Object.keys(currentNode.usedBy)[0] === name)[0] + ); + level++; + } + + return level; +} +async function updateShowInfoMenu(params) { + if (params.nodes.length === 0) { + window.networkNav.currentNodeParams = null; + + return PackageInfo.close(); + } + + if (window.networkNav.currentNodeParams?.nodes[0] === params.nodes[0] && packageInfoOpened === true) { + return void 0; + } + + packageInfoOpened = true; + window.networkNav.currentNodeParams = params; + const currentNode = window.networkNav.currentNodeParams.nodes[0]; + const selectedNode = secureDataSet.linker.get( + Number(currentNode) + ); + const selectedNodeLevel = getNodeLevel(selectedNode); + + window.networkNav.setLevel(selectedNodeLevel); + if (window.networkNav.dependenciesMapByLevel.get(selectedNodeLevel) === undefined) { + window.networkNav.dependenciesMapByLevel.set(selectedNodeLevel, params); + } + + new PackageInfo(selectedNode, currentNode, secureDataSet.data.dependencies[selectedNode.name], nsn); + + return void 0; +} + +function onSettingsSaved() { window.addEventListener("settings-saved", async(event) => { const warningsToIgnore = new Set(event.detail.ignore.warnings); const flagsToIgnore = new Set(event.detail.ignore.flags); @@ -86,57 +203,20 @@ document.addEventListener("DOMContentLoaded", async() => { ); const { nodes } = secureDataSet.build(); nsn.nodes.update(nodes.get()); + const rootNode = secureDataSet.linker.get(0); + window.activePackage = rootNode.name; - if (networkNavigation.currentNodeParams !== null) { + if (window.networkNav.currentNodeParams !== null) { window.navigation.setNavByName("network--view"); - nsn.neighbourHighlight(networkNavigation.currentNodeParams, window.i18n[utils.currentLang()]); - updateShowInfoMenu(networkNavigation.currentNodeParams); + nsn.neighbourHighlight(window.networkNav.currentNodeParams, window.i18n[utils.currentLang()]); + updateShowInfoMenu(window.networkNav.currentNodeParams); } if (event.detail.showFriendlyDependencies) { - legend.show(); + window.legend.show(); } else { - legend.hide(); + window.legend.hide(); } }); - - // Initialize searchbar - { - const dataListElement = document.getElementById("package-list"); - for (const info of secureDataSet.packages) { - const content = `

${info.flags} ${info.name}

${info.version}`; - dataListElement.insertAdjacentHTML("beforeend", `
${content}
`); - } - } - window.searchbar = new SearchBar(nsn, secureDataSet.linker); - - async function updateShowInfoMenu(params) { - if (params.nodes.length === 0) { - networkNavigation.currentNodeParams = null; - - return PackageInfo.close(); - } - - if (networkNavigation.currentNodeParams?.nodes[0] === params.nodes[0] && packageInfoOpened === true) { - return void 0; - } - - packageInfoOpened = true; - networkNavigation.currentNodeParams = params; - const currentNode = networkNavigation.currentNodeParams.nodes[0]; - const selectedNode = secureDataSet.linker.get( - Number(currentNode) - ); - const selectedNodeLevel = getNodeLevel(selectedNode); - - networkNavigation.setLevel(selectedNodeLevel); - if (networkNavigation.dependenciesMapByLevel.get(selectedNodeLevel) === undefined) { - networkNavigation.dependenciesMapByLevel.set(selectedNodeLevel, params); - } - - new PackageInfo(selectedNode, currentNode, secureDataSet.data.dependencies[selectedNode.name], nsn); - - return void 0; - } -}); +} diff --git a/scripts/clear-cache.js b/scripts/clear-cache.js new file mode 100644 index 00000000..06577b47 --- /dev/null +++ b/scripts/clear-cache.js @@ -0,0 +1,9 @@ +// Import Third-party Dependencies +import cacache from "cacache"; + +// Import Internal Dependencies +import { CACHE_PATH } from "../src/http-server/cache.js"; + +await cacache.rm.all(CACHE_PATH); + +console.log("Cache cleared successfully!"); diff --git a/src/commands/scorecard.js b/src/commands/scorecard.js index dafc2ff8..47b4d348 100644 --- a/src/commands/scorecard.js +++ b/src/commands/scorecard.js @@ -41,7 +41,7 @@ export async function main(repo, opts) { try { const [repo, vcs] = result.unwrap(); repository = repo; - platform = vcs.slice(-4) === ".com" ? vsc : `${vcs}.com`; + platform = vcs.slice(-4) === ".com" ? vcs : `${vcs}.com`; } catch (error) { console.log(white().bold(result.err)); diff --git a/src/http-server/cache.js b/src/http-server/cache.js new file mode 100644 index 00000000..c2b9a753 --- /dev/null +++ b/src/http-server/cache.js @@ -0,0 +1,132 @@ +// Import Node.js Dependencies +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; + +// Import Third-party Dependencies +import cacache from "cacache"; + +// Import Internal Dependencies +import { logger } from "./logger.js"; + +// CONSTANTS +const kConfigCache = "___config"; +const kPayloadsCache = "___payloads"; +const kPayloadsPath = path.join(os.homedir(), ".nsecure", "payloads"); +const kMaxPayloads = 3; + +export const CACHE_PATH = path.join(os.tmpdir(), "nsecure-cli"); +export const DEFAULT_PAYLOAD_PATH = path.join(process.cwd(), "nsecure-result.json"); + +class _AppCache { + constructor() { + fs.mkdirSync(kPayloadsPath, { recursive: true }); + } + + async updateConfig(newValue) { + await cacache.put(CACHE_PATH, kConfigCache, JSON.stringify(newValue)); + } + + async getConfig() { + const { data } = await cacache.get(CACHE_PATH, kConfigCache); + + return JSON.parse(data.toString()); + } + + updatePayload(pkg, payload) { + fs.writeFileSync(path.join(kPayloadsPath, pkg), JSON.stringify(payload)); + } + + async getPayload(pkg) { + try { + return JSON.parse(fs.readFileSync(path.join(kPayloadsPath, pkg.replaceAll("/", "-")), "utf-8")); + } + catch (err) { + logger.error(`[cache|get](pkg: ${pkg}|cache: not found)`); + + throw err; + } + } + + async getPayloadOrNull(pkg) { + try { + return await this.getPayload(pkg); + } + catch { + return null; + } + } + + async updatePayloadsList(payloadsList) { + await cacache.put(CACHE_PATH, kPayloadsCache, JSON.stringify(payloadsList)); + } + + async payloadsList() { + try { + const { data } = await cacache.get(CACHE_PATH, kPayloadsCache); + + return JSON.parse(data.toString()); + } + catch (err) { + logger.error(`[cache|get](cache: not found)`); + + throw err; + } + } + + async #initDefaultPayloadsList() { + const payload = JSON.parse(fs.readFileSync(DEFAULT_PAYLOAD_PATH, "utf-8")); + const version = Object.keys(payload.dependencies[payload.rootDependencyName].versions)[0]; + const formatted = `${payload.rootDependencyName}@${version}`; + const payloadsList = { + lru: [formatted], + current: formatted, + older: [], + lastUsed: { + [formatted]: Date.now() + }, + root: formatted + }; + + logger.info(`[cache|init](dep: ${formatted}|version: ${version}|rootDependencyName: ${payload.rootDependencyName})`); + await cacache.put(CACHE_PATH, kPayloadsCache, JSON.stringify(payloadsList)); + this.updatePayload(formatted.replaceAll("/", "-"), payload); + } + + async initPayloadsList() { + const packagesInFolder = fs.readdirSync(kPayloadsPath); + if (packagesInFolder.length === 0) { + this.#initDefaultPayloadsList(); + + return; + } + + const list = packagesInFolder.map(({ name }) => name); + logger.info(`[cache|init](list: ${list})`); + + await cacache.put(CACHE_PATH, kPayloadsCache, JSON.stringify({ list, current: list[0] })); + } + + removePayload(pkg) { + fs.rmSync(path.join(kPayloadsPath, pkg)); + } + + async removeLastLRU() { + const { lru, lastUsed, older, root } = await this.payloadsList(); + if (lru.length < kMaxPayloads) { + return { lru, older, lastUsed, root }; + } + const packageToBeRemoved = Object.keys(lastUsed) + .filter((key) => lru.includes(key)) + .sort((a, b) => lastUsed[a] - lastUsed[b])[0]; + + return { + lru: lru.filter((pkg) => pkg !== packageToBeRemoved), + older: [...older, packageToBeRemoved], + lastUsed, + root + }; + } +} + +export const appCache = new _AppCache(); diff --git a/src/http-server/config.js b/src/http-server/config.js index 02aa02a5..4f0998d5 100644 --- a/src/http-server/config.js +++ b/src/http-server/config.js @@ -1,32 +1,49 @@ -// Import Node.js Dependencies -import path from "node:path"; -import os from "node:os"; - -// Import Third-party Depedencies -import cacache from "cacache"; +// Import Internal Dependencies +import { appCache } from "./cache.js"; +import { logger } from "./logger.js"; // CONSTANTS -const kCachePath = path.join(os.tmpdir(), "nsecure-cli"); -const kConfigKey = "cli-config"; +const kDefaultConfig = { + defaultPackageMenu: "info", + ignore: { flags: [], warnings: [] } +}; export async function get() { try { - const { data } = await cacache.get(kCachePath, kConfigKey); + const config = await appCache.getConfig(); + + const { + defaultPackageMenu, + ignore: { + flags, + warnings + } = {} + } = config; + logger.info(`[config|get](defaultPackageMenu: ${defaultPackageMenu}|ignore-flag: ${flags}|ignore-warnings: ${warnings})`); - return JSON.parse(data.toString()); + return config; } - catch { - const defaultValue = { - defaultPackageMenu: "info", - ignore: { flags: [], warnings: [] } - }; + catch (err) { + logger.error(`[config|get](error: ${err.message})`); - await cacache.put(kCachePath, kConfigKey, JSON.stringify(defaultValue)); + await appCache.updateConfig(kDefaultConfig); - return defaultValue; + logger.info(`[config|get](fallback to default: ${JSON.stringify(kDefaultConfig)})`); + + return kDefaultConfig; } } export async function set(newValue) { - await cacache.put(kCachePath, kConfigKey, JSON.stringify(newValue)); + logger.info(`[config|set](config: ${newValue})`); + try { + await appCache.updateConfig(newValue); + + logger.info(`[config|set](sucess)`); + } + catch (err) { + logger.error(`[config|set](error: ${err.message})`); + + throw err; + } } diff --git a/src/http-server/endpoints/data.js b/src/http-server/endpoints/data.js index 6a5e34d8..02194a86 100644 --- a/src/http-server/endpoints/data.js +++ b/src/http-server/endpoints/data.js @@ -1,18 +1,47 @@ // Import Node.js Dependencies import fs from "node:fs"; -import { pipeline } from "node:stream"; +import path from "node:path"; + +// Import Third-party Dependencies +import send from "@polka/send-type"; // Import Internal Dependencies -import { context } from "../context.js"; +import { appCache } from "../cache.js"; +import { logger } from "../logger.js"; + +// CONSTANTS +const kDefaultPayloadPath = path.join(process.cwd(), "nsecure-result.json"); export async function get(req, res) { - const { dataFilePath } = context.getStore(); + try { + const { current, lru } = await appCache.payloadsList(); + logger.info(`[data|get](current: ${current})`); + logger.debug(`[data|get](lru: ${lru})`); + + const formatted = current.replaceAll("/", "-"); + send(res, 200, await appCache.getPayload(formatted)); + } + catch { + logger.error(`[data|get](No cache yet. Creating one...)`); + + const payload = JSON.parse(fs.readFileSync(kDefaultPayloadPath, "utf-8")); + const version = Object.keys(payload.dependencies[payload.rootDependencyName].versions)[0]; + const formatted = `${payload.rootDependencyName}@${version}`; + const payloadsList = { + lru: [formatted], + current: formatted, + older: [], + lastUsed: { + [formatted]: Date.now() + }, + root: formatted + }; + logger.info(`[data|get](dep: ${formatted}|version: ${version}|rootDependencyName: ${payload.rootDependencyName})`); - res.writeHead(200, { "Content-Type": "application/json" }); + await appCache.updatePayloadsList(payloadsList); + appCache.updatePayload(formatted.replaceAll("/", "-"), payload); + logger.info(`[data|get](cache: created|payloadsList: ${payloadsList.lru})`); - pipeline(fs.createReadStream(dataFilePath), res, (err) => { - if (err) { - console.error(err); - } - }); + send(res, 200, payload); + } } diff --git a/src/http-server/endpoints/search.js b/src/http-server/endpoints/search.js new file mode 100644 index 00000000..2f221981 --- /dev/null +++ b/src/http-server/endpoints/search.js @@ -0,0 +1,41 @@ +// Import Third-party Dependencies +import send from "@polka/send-type"; +import * as npm from "@nodesecure/npm-registry-sdk"; + +// Import Internal Dependencies +import { logger } from "../logger.js"; + +export async function get(req, res) { + const { packageName } = req.params; + logger.info(`[search|get](packageName: ${packageName}|formatted: ${decodeURIComponent(packageName)})`); + + const { objects, total } = await npm.search({ + text: decodeURIComponent(packageName) + }); + logger.debug(`[search|get](npmSearchResult: ${JSON.stringify(objects.map((pkg) => pkg.package.name))})`); + + send(res, 200, { + count: total, + result: objects.map((pkg) => { + return { + name: pkg.package.name, + version: pkg.package.version, + description: pkg.package.description + }; + }) + }); +} + +export async function versions(req, res) { + const { packageName } = req.params; + + logger.info(`[search|versions](packageName: ${packageName}|formatted: ${decodeURIComponent(packageName)})`); + + const packument = await npm.packument(decodeURIComponent(packageName)); + const versions = Object.keys(packument.versions); + + logger.info(`[search|versions](packageName: ${packageName}|versions: ${versions})`); + logger.debug(`[search|versions](packument: ${packument})`); + + send(res, 200, versions); +} diff --git a/src/http-server/index.js b/src/http-server/index.js index 93cfa9e2..bab1b82d 100644 --- a/src/http-server/index.js +++ b/src/http-server/index.js @@ -6,22 +6,27 @@ import kleur from "kleur"; import polka from "polka"; import open from "open"; import * as i18n from "@nodesecure/i18n"; +import { WebSocketServer } from "ws"; // Import Internal Dependencies import * as root from "./endpoints/root.js"; import * as data from "./endpoints/data.js"; import * as flags from "./endpoints/flags.js"; import * as config from "./endpoints/config.js"; +import * as search from "./endpoints/search.js"; import * as bundle from "./endpoints/bundle.js"; import * as npmDownloads from "./endpoints/npm-downloads.js"; import * as scorecard from "./endpoints/ossf-scorecard.js"; import * as locali18n from "./endpoints/i18n.js"; import * as report from "./endpoints/report.js"; import * as middleware from "./middleware.js"; +import * as wsHandlers from "./websocket/index.js"; +import { logger } from "./logger.js"; export function buildServer(dataFilePath, options = {}) { const httpConfigPort = typeof options.port === "number" ? options.port : 0; const openLink = typeof options.openLink === "boolean" ? options.openLink : true; + const enableWS = options.enableWS ?? process.env.NODE_ENV !== "test"; fs.accessSync(dataFilePath, fs.constants.R_OK | fs.constants.W_OK); @@ -30,12 +35,15 @@ export function buildServer(dataFilePath, options = {}) { httpServer.use(middleware.buildContextMiddleware(dataFilePath)); httpServer.use(middleware.addStaticFiles); httpServer.get("/", root.get); - httpServer.get("/data", data.get); + httpServer.get("/data", data.get); httpServer.get("/config", config.get); httpServer.put("/config", config.save); httpServer.get("/i18n", locali18n.get); + httpServer.get("/search/:packageName", search.get); + httpServer.get("/search-versions/:packageName", search.versions); + httpServer.get("/flags", flags.getAll); httpServer.get("/flags/description/:title", flags.get); httpServer.get("/bundle/:pkgName", bundle.get); @@ -54,5 +62,29 @@ export function buildServer(dataFilePath, options = {}) { } }); + if (enableWS) { + const websocket = new WebSocketServer({ port: 1338 }); + websocket.on("connection", async(socket) => { + socket.on("message", async(rawMessage) => { + const message = JSON.parse(rawMessage); + logger.info(`[ws](message: ${JSON.stringify(message)})`); + + if (message.action === "SEARCH") { + wsHandlers.search(socket, message.pkg); + } + else if (message.action === "REMOVE") { + wsHandlers.remove(socket, message.pkg); + } + }); + + wsHandlers.init(socket); + }); + } + return httpServer; } + +process.on("SIGINT", () => { + console.log(kleur.red().bold("SIGINT signal received.")); + process.exit(0); +}); diff --git a/src/http-server/logger.js b/src/http-server/logger.js new file mode 100644 index 00000000..e313792e --- /dev/null +++ b/src/http-server/logger.js @@ -0,0 +1,14 @@ +// Import Third-party Dependencies +import pino from "pino"; + +// CONSTANTS +const kDefaultLogLevel = "info"; + +const logger = pino({ + level: process.env.LOG_LEVEL ?? kDefaultLogLevel, + transport: { + target: "pino-pretty" + } +}); + +export { logger }; diff --git a/src/http-server/websocket/index.js b/src/http-server/websocket/index.js new file mode 100644 index 00000000..9c19c095 --- /dev/null +++ b/src/http-server/websocket/index.js @@ -0,0 +1,3 @@ +export * from "./search.js"; +export * from "./remove.js"; +export * from "./init.js"; diff --git a/src/http-server/websocket/init.js b/src/http-server/websocket/init.js new file mode 100644 index 00000000..fe3a84ac --- /dev/null +++ b/src/http-server/websocket/init.js @@ -0,0 +1,33 @@ +// Import Internal Dependencies +import { appCache } from "../cache.js"; +import { logger } from "../logger.js"; + +export async function init(socket, lock = false) { + try { + const { current, lru, older, root } = await appCache.payloadsList(); + logger.info(`[ws|init](lru: ${lru}|older: ${older}|current: ${current}|root: ${root})`); + + if (lru === void 0 || current === void 0) { + throw new Error("Payloads list not found in cache."); + } + + socket.send(JSON.stringify({ + status: "INIT", + current, + lru, + older, + root + })); + } + catch { + logger.error(`[ws|init](No cache yet. Creating one...)`); + + if (lock) { + return; + } + + await appCache.initPayloadsList(); + + init(socket, true); + } +} diff --git a/src/http-server/websocket/remove.js b/src/http-server/websocket/remove.js new file mode 100644 index 00000000..005529fe --- /dev/null +++ b/src/http-server/websocket/remove.js @@ -0,0 +1,85 @@ +// Import Internal Dependencies +import { appCache } from "../cache.js"; +import { logger } from "../logger.js"; + +export async function remove(ws, pkg) { + const formattedPkg = pkg.replace("/", "-"); + logger.info(`[ws|remove](pkg: ${pkg}|formatted: ${formattedPkg})`); + + try { + const { lru, older, current, lastUsed, root } = await appCache.payloadsList(); + logger.debug(`[ws|remove](lru: ${lru}|current: ${current})`); + + if (lru.length === 1 && older.length === 0) { + throw new Error("Cannot remove the last package."); + } + + const lruIndex = lru.findIndex((pkgName) => pkgName === pkg); + const olderIndex = older.findIndex((pkgName) => pkgName === pkg); + + if (lruIndex === -1 && olderIndex === -1) { + throw new Error("Package not found in cache."); + } + + if (lruIndex > -1) { + logger.info(`[ws|remove](remove from lru)`); + const updatedLru = lru.filter((pkgName) => pkgName !== pkg); + if (older.length > 0) { + // We need to move the first older package to the lru list + const olderPkg = older.sort((a, b) => { + const aDate = lastUsed[a]; + const bDate = lastUsed[b]; + + return aDate - bDate; + }); + updatedLru.push(olderPkg[0]); + older.splice(older.indexOf(olderPkg[0]), 1); + } + + const updatedList = { + lru: updatedLru, + older, + lastUsed: { + ...lastUsed, + [pkg]: void 0 + }, + current: current === pkg ? updatedLru[0] : current, + root + }; + await appCache.updatePayloadsList(updatedList); + + ws.send(JSON.stringify({ + status: "RELOAD", + ...updatedList + })); + } + else { + logger.info(`[ws|remove](remove from older)`); + const updatedOlder = older.filter((pkgName) => pkgName !== pkg); + const updatedList = { + lru, + older: updatedOlder, + lastUsed: { + ...lastUsed, + [pkg]: void 0 + }, + current, + root + }; + await appCache.updatePayloadsList(updatedList); + + ws.send(JSON.stringify({ + status: "RELOAD", + ...updatedList + })); + } + + appCache.removePayload(formattedPkg.replaceAll("/", "-")); + } + catch (error) { + logger.error(`[ws|remove](error: ${error.message})`); + logger.debug(error); + + throw error; + } +} diff --git a/src/http-server/websocket/search.js b/src/http-server/websocket/search.js new file mode 100644 index 00000000..8837b045 --- /dev/null +++ b/src/http-server/websocket/search.js @@ -0,0 +1,80 @@ +// Import Third-party Dependencies +import * as Scanner from "@nodesecure/scanner"; + +// Import Internal Dependencies +import { logger } from "../logger.js"; +import { appCache } from "../cache.js"; + +export async function search(ws, pkg) { + logger.info(`[ws|search](pkg: ${pkg})`); + + const cache = await appCache.getPayloadOrNull(pkg); + if (cache) { + logger.info(`[ws|search](payload: ${pkg} found in cache)`); + const cacheList = await appCache.payloadsList(); + if (cacheList.lru.includes(pkg)) { + logger.info(`[ws|search](payload: ${pkg} is already in the LRU)`); + const updatedList = { + ...cacheList, + current: pkg, + lastUsed: { ...cacheList.lastUsed, [pkg]: Date.now() } + }; + await appCache.updatePayloadsList(updatedList); + ws.send(JSON.stringify(cache)); + + return; + } + const { lru, older, lastUsed, root } = await appCache.removeLastLRU(); + const updatedList = { + lru: [...new Set([...lru, pkg])], + current: pkg, + older: older.filter((pckg) => pckg !== pkg), + lastUsed: { ...lastUsed, [pkg]: Date.now() }, + root + }; + await appCache.updatePayloadsList(updatedList); + + ws.send(JSON.stringify(cache)); + ws.send(JSON.stringify({ + status: "RELOAD", + ...updatedList + })); + + return; + } + + // at this point we don't have the payload in cache so we have to scan it. + logger.info(`[ws|search](scan ${pkg} in progress)`); + ws.send(JSON.stringify({ status: "SCAN", pkg })); + + const payload = await Scanner.from(pkg, { maxDepth: 4 }); + const name = payload.rootDependencyName; + const version = Object.keys(payload.dependencies[name].versions)[0]; + + { + // save the payload in cache + const pkg = `${name}@${version}`; + logger.info(`[ws|search](scan ${pkg} done|cache: updated)`); + + // update the payloads list + const { lru, older, lastUsed, root } = await appCache.removeLastLRU(); + lru.push(pkg); + appCache.updatePayload(pkg.replaceAll("/", "-"), payload); + const updatedList = { + lru: [...new Set(lru)], + older, + lastUsed: { ...lastUsed, [pkg]: Date.now() }, + current: pkg, + root + }; + await appCache.updatePayloadsList(updatedList); + + ws.send(JSON.stringify(payload)); + ws.send(JSON.stringify({ + status: "RELOAD", + ...updatedList + })); + + logger.info(`[ws|search](data sent to client|cache: updated)`); + } +} diff --git a/test/config.test.js b/test/config.test.js index d2db3b1f..37c79d11 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -1,6 +1,4 @@ // Import Node.js Dependencies -import path from "node:path"; -import os from "node:os"; import { describe, it, before, after } from "node:test"; import assert from "node:assert"; @@ -9,12 +7,12 @@ import cacache from "cacache"; // Import Internal Dependencies import { get, set } from "../src/http-server/config.js"; +import { CACHE_PATH } from "../src/http-server/cache.js"; // CONSTANTS -const kCachePath = path.join(os.tmpdir(), "nsecure-cli"); -const kConfigKey = "cli-config"; +const kConfigKey = "___config"; -describe("config", () => { +describe("config", { concurrency: 1 }, () => { let actualConfig; before(async() => { @@ -26,7 +24,7 @@ describe("config", () => { }); it("should get default config from empty cache", async() => { - await cacache.rm(kCachePath, kConfigKey); + await cacache.rm(CACHE_PATH, kConfigKey); const value = await get(); assert.deepStrictEqual(value, { @@ -36,7 +34,7 @@ describe("config", () => { }); it("should get config from cache", async() => { - await cacache.put(kCachePath, kConfigKey, JSON.stringify({ foo: "bar" })); + await cacache.put(CACHE_PATH, kConfigKey, JSON.stringify({ foo: "bar" })); const value = await get(); assert.deepStrictEqual(value, { foo: "bar" }); diff --git a/test/httpServer.test.js b/test/httpServer.test.js index c2ae7178..f59500d4 100644 --- a/test/httpServer.test.js +++ b/test/httpServer.test.js @@ -1,10 +1,9 @@ // Import Node.js Dependencies -import { readFileSync } from "node:fs"; +import fs from "node:fs"; import { fileURLToPath } from "node:url"; import { after, before, describe, test } from "node:test"; import { once } from "node:events"; import path from "node:path"; -import os from "node:os"; import assert from "node:assert"; // Import Third-party Dependencies @@ -18,6 +17,7 @@ import cacache from "cacache"; // Require Internal Dependencies import { buildServer } from "../src/http-server/index.js"; +import { CACHE_PATH } from "../src/http-server/cache.js"; // CONSTANTS const HTTP_PORT = 17049; @@ -25,33 +25,40 @@ const HTTP_URL = new URL(`http://localhost:${HTTP_PORT}`); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const JSON_PATH = path.join(__dirname, "fixtures", "httpServer.json"); -const INDEX_HTML = readFileSync(path.join(__dirname, "..", "views", "index.html"), "utf-8"); +const INDEX_HTML = fs.readFileSync(path.join(__dirname, "..", "views", "index.html"), "utf-8"); -const kCachePath = path.join(os.tmpdir(), "nsecure-cli"); -const kConfigKey = "cli-config"; +const kConfigKey = "___config"; const kGlobalDispatcher = getGlobalDispatcher(); const kMockAgent = new MockAgent(); const kBundlephobiaPool = kMockAgent.get("https://bundlephobia.com"); +const kDefaultPayloadPath = path.join(process.cwd(), "nsecure-result.json"); -describe("httpServer", () => { +describe("httpServer", { concurrency: 1 }, () => { let httpServer; before(async() => { setGlobalDispatcher(kMockAgent); + await i18n.extendFromSystemPath( + path.join(__dirname, "..", "i18n") + ); httpServer = buildServer(JSON_PATH, { port: HTTP_PORT, - openLink: false + openLink: false, + enableWS: false }); await once(httpServer.server, "listening"); - await i18n.extendFromSystemPath( - path.join(__dirname, "..", "i18n") - ); enableDestroy(httpServer.server); + + if (fs.existsSync(kDefaultPayloadPath) === false) { + // When running tests on CI, we need to create the nsecure-result.json file + const payload = fs.readFileSync(JSON_PATH, "utf-8"); + fs.writeFileSync(kDefaultPayloadPath, payload); + } }, { timeout: 5000 }); - after(() => { + after(async() => { httpServer.server.destroy(); kBundlephobiaPool.close(); setGlobalDispatcher(kGlobalDispatcher); @@ -123,7 +130,6 @@ describe("httpServer", () => { createReadStream: () => "foo" } }); - const consoleError = console.error; const logs = []; console.error = (data) => logs.push(data); @@ -135,31 +141,7 @@ describe("httpServer", () => { const result = await get(new URL("/data", HTTP_URL)); assert.equal(result.statusCode, 200); - assert.equal(result.headers["content-type"], "application/json"); - }); - - test("'/data' should fail", async() => { - const module = await esmock("../src/http-server/endpoints/data.js", { - "../src/http-server/context.js": { - context: { - getStore: () => { - return { dataFilePath: "foo" }; - } - } - }, - stream: { - pipeline: (stream, res, err) => err("fake error") - }, - fs: { - createReadStream: () => "foo" - } - }); - const consoleError = console.error; - const logs = []; - console.error = (data) => logs.push(data); - - await module.get({}, ({ writeHead: () => true })); - assert.deepEqual(logs, ["fake error"]); + assert.equal(result.headers["content-type"], "application/json;charset=utf-8"); }); test("'/bundle/:name/:version' should return the bundle size", async() => { @@ -230,7 +212,7 @@ describe("httpServer", () => { test("GET '/config' should return the config", async() => { const { data: actualConfig } = await get(new URL("/config", HTTP_URL)); - await cacache.put(kCachePath, kConfigKey, JSON.stringify({ foo: "bar" })); + await cacache.put(CACHE_PATH, kConfigKey, JSON.stringify({ foo: "bar" })); const result = await get(new URL("/config", HTTP_URL)); assert.deepEqual(result.data, { foo: "bar" }); @@ -254,7 +236,7 @@ describe("httpServer", () => { assert.equal(status, 204); - const inCache = await cacache.get(kCachePath, kConfigKey); + const inCache = await cacache.get(CACHE_PATH, kConfigKey); assert.deepEqual(JSON.parse(inCache.data.toString()), { fooz: "baz" }); await fetch(new URL("/config", HTTP_URL), { @@ -327,11 +309,22 @@ describe("httpServer", () => { const json = JSON.parse(result.data); assert.strictEqual(json.data.type, "Buffer"); }); + + test("'/search' should return the package list", async() => { + const result = await get(new URL("/search/nodesecure", HTTP_URL)); + + assert.equal(result.statusCode, 200); + assert.ok(result.data); + assert.ok(Array.isArray(result.data.result)); + assert.ok(result.data.count); + }); }); describe("httpServer without options", () => { let httpServer; let opened = false; + // We want to disable WS + process.env.NODE_ENV = "test"; before(async() => { const module = await esmock("../src/http-server/index.js", { @@ -343,7 +336,7 @@ describe("httpServer without options", () => { enableDestroy(httpServer.server); }); - after(() => { + after(async() => { httpServer.server.destroy(); }); diff --git a/views/index.html b/views/index.html index 3f11668b..c43afa67 100644 --- a/views/index.html +++ b/views/index.html @@ -9,18 +9,19 @@ + NodeSecure
-