├── .eslintrc ├── .github ├── scripts │ ├── compare-catalogs.js │ └── generate-catalog.sh └── workflows │ ├── build.yml │ └── downloadStats.yml ├── .gitignore ├── .nodemonignore ├── .vscode └── launch.json ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── app.js ├── config └── index.js ├── default-settings.js ├── docker-compose.yml ├── lib ├── aws.js ├── categories.js ├── collections.js ├── db.js ├── events.js ├── gists.js ├── github.js ├── modules.js ├── nodes.js ├── ratings.js ├── scorecard.js ├── templates.js ├── users.js ├── utils.js └── view.js ├── package-lock.json ├── package.json ├── public ├── css │ ├── cc.css │ ├── library.css │ └── style.min.css ├── favicon.ico ├── font-awesome │ ├── css │ │ └── font-awesome.min.css │ └── fonts │ │ ├── FontAwesome.otf │ │ ├── fontawesome-webfont.eot │ │ ├── fontawesome-webfont.svg │ │ ├── fontawesome-webfont.ttf │ │ ├── fontawesome-webfont.woff │ │ └── fontawesome-webfont.woff2 ├── font │ ├── config.json │ ├── fontello.eot │ ├── fontello.svg │ ├── fontello.ttf │ ├── fontello.woff │ └── fontello.woff2 ├── icons │ ├── alert.png │ ├── arduino.png │ ├── arrow-in.png │ ├── bluetooth.png │ ├── bridge-dash.png │ ├── bridge.png │ ├── comment.png │ ├── db.png │ ├── debug.png │ ├── envelope.png │ ├── feed.png │ ├── file.png │ ├── function.png │ ├── hash.png │ ├── inject.png │ ├── leveldb.png │ ├── light.png │ ├── mongodb.png │ ├── mouse.png │ ├── node-changed.png │ ├── node-error.png │ ├── range.png │ ├── redis.png │ ├── rpi.png │ ├── serial.png │ ├── subflow.png │ ├── swap.png │ ├── switch.png │ ├── template.png │ ├── timer.png │ ├── trigger.png │ ├── twitter.png │ ├── watch.png │ └── white-globe.png ├── images │ ├── add-to-collection.gif │ ├── flow01.png │ ├── loader.gif │ ├── spin.svg │ └── user-backdrop.svg ├── jquery │ ├── css │ │ └── smoothness │ │ │ ├── images │ │ │ ├── animated-overlay.gif │ │ │ ├── ui-bg_flat_0_aaaaaa_40x100.png │ │ │ ├── ui-bg_flat_75_ffffff_40x100.png │ │ │ ├── ui-bg_glass_55_fbf9ee_1x400.png │ │ │ ├── ui-bg_glass_65_ffffff_1x400.png │ │ │ ├── ui-bg_glass_75_dadada_1x400.png │ │ │ ├── ui-bg_glass_75_e6e6e6_1x400.png │ │ │ ├── ui-bg_glass_95_fef1ec_1x400.png │ │ │ ├── ui-bg_highlight-soft_75_cccccc_1x100.png │ │ │ ├── ui-icons_222222_256x240.png │ │ │ ├── ui-icons_2e83ff_256x240.png │ │ │ ├── ui-icons_454545_256x240.png │ │ │ ├── ui-icons_888888_256x240.png │ │ │ └── ui-icons_cd0a0a_256x240.png │ │ │ └── jquery-ui-1.10.3.custom.min.css │ └── js │ │ ├── jquery-1.9.1.js │ │ └── jquery-ui-1.10.3.custom.min.js ├── js │ ├── cc.min.js │ ├── flowviewer.js │ ├── highlight.min.js │ ├── jquery.raty-fa.js │ ├── marked.js │ ├── tags.js │ └── utils.js ├── node-red-white.png └── node-red.png ├── routes ├── admin.js ├── api.js ├── auth.js ├── categories.js ├── collections.js ├── flows.js ├── index.js ├── nodes.js └── users.js ├── tasks ├── add-gist.js ├── generate_catalog.js ├── refresh-gist.js ├── remove-gist.js ├── update-download-stats.js └── update-one.js ├── template ├── 404.html ├── _D01.html ├── _D02.html ├── _D03.html ├── _N01.html ├── _N02.html ├── _P01.html ├── _P02.html ├── _P03.html ├── _P04.html ├── _P05.html ├── _P06.html ├── _P07.html ├── _P08.html ├── _category.html ├── _categoryBox.html ├── _collectionNavBox.html ├── _collectionbox.html ├── _cookies.html ├── _event.html ├── _flowViewer.html ├── _footer.html ├── _gistbox.html ├── _gistboxTools.html ├── _gistitems.html ├── _gistlist.html ├── _header.html ├── _nodebox.html ├── _nodelist.html ├── _palettenode.html ├── _rateTools.html ├── _rulemodal.html ├── _scorecardResult.html ├── _tagTools.html ├── _taglist.html ├── _toolbar.html ├── add.html ├── addCategory.html ├── addCollection.html ├── addFlow.html ├── addNode.html ├── categories.html ├── category.html ├── collection.html ├── events.html ├── flowInspector.html ├── gist.html ├── gistShare.html ├── index.html ├── maintenance.html ├── node.html ├── scorecard.html ├── search.html ├── tag.html ├── user.html └── userSettings.html └── test └── lib ├── modules_spec.js └── ratings_spec.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "es2022": true, 5 | "commonjs": true 6 | }, 7 | "extends": [ 8 | "standard", 9 | "plugin:import/recommended", 10 | "plugin:promise/recommended", 11 | "plugin:n/recommended" 12 | ], 13 | "parserOptions": { 14 | "ecmaVersion": 2022 15 | }, 16 | // "ignorePatterns": ["frontend/dist/", "var/", "*.svg", "*.xml"], 17 | "plugins": ["promise", "no-only-tests"], 18 | "rules": { 19 | // Inbuilt 20 | "indent": ["error", 4], 21 | "object-shorthand": ["error"], 22 | "sort-imports": [ 23 | "error", 24 | { 25 | "ignoreDeclarationSort": true 26 | } 27 | ], 28 | // "no-console": ["info", { "allow": ["debug", "info", "warn", "error"] }], 29 | 30 | // plugin:import 31 | "import/order": [ 32 | "error", 33 | { 34 | "alphabetize": { 35 | "order": "asc" 36 | }, 37 | "newlines-between": "always-and-inside-groups" 38 | } 39 | ], 40 | "import/no-unresolved": "error", 41 | 42 | // plugin:n 43 | "n/file-extension-in-import": "error", 44 | "n/no-missing-import": "error", 45 | "n/no-missing-require": "error", 46 | 47 | // plugin:no-only-tests 48 | "no-only-tests/no-only-tests": "error", 49 | 50 | // plugin:promise 51 | "promise/catch-or-return": ["error", { "allowFinally": true }] 52 | }, 53 | "overrides": [ 54 | { 55 | "files": ["test/**"], 56 | "env": { 57 | "mocha": true 58 | } 59 | }, 60 | { 61 | "files": ["public/js/**"], 62 | "env": { 63 | "browser": true, 64 | "jquery": true 65 | } 66 | } 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /.github/scripts/compare-catalogs.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | 3 | var currentFile = JSON.parse(fs.readFileSync("catalogue.nodered.org/catalogue.json")) 4 | var newFile = JSON.parse(fs.readFileSync("catalogue.nodered.org/catalogue.json.new")) 5 | 6 | var currentModules = JSON.stringify(currentFile.modules); 7 | var newModules = JSON.stringify(newFile.modules); 8 | 9 | if (currentModules !== newModules) { 10 | process.exit(1) 11 | } 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/scripts/generate-catalog.sh: -------------------------------------------------------------------------------- 1 | echo "Generating catalogue" 2 | node ./flow-library/tasks/generate_catalog.js > ./catalogue.nodered.org/catalogue.json.new 3 | if [ $? -ne 0 ]; then 4 | exit 5 | fi 6 | 7 | node ./flow-library/.github/scripts/compare-catalogs.js 8 | RC=$? 9 | echo RC=$RC 10 | if [ $RC -eq 0 ]; then 11 | echo "Nothing to do" 12 | exit 13 | fi 14 | 15 | echo "Updating catalogue" 16 | cd catalogue.nodered.org 17 | mv catalogue.json.new catalogue.json 18 | echo "Node-RED community node module catalogue" > index.html 19 | echo "Updated: " >> index.html 20 | date >> index.html 21 | git config --global user.name "node-red-flow-library" 22 | git config --global user.email "node-red-flow-library@users.noreply.github.com" 23 | git add catalogue.json index.html 24 | git commit -m "Update catalogue" 25 | git push origin master -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: PublishUpdatedCatalog 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '*/30 * * * *' 7 | jobs: 8 | generate: 9 | name: 'Update Flow Library Catalogue' 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out flow-library repository 13 | uses: actions/checkout@v2 14 | with: 15 | path: 'flow-library' 16 | - name: Check out catalogue.nodered.org repository 17 | uses: actions/checkout@v2 18 | with: 19 | repository: 'node-red/catalogue.nodered.org' 20 | path: 'catalogue.nodered.org' 21 | token: ${{ secrets.FLOW_LIBRARY_PAT }} 22 | ref: 'master' 23 | - uses: actions/setup-node@v1 24 | with: 25 | node-version: '18' 26 | - name: npm install 27 | run: | 28 | cd flow-library 29 | npm install --only=production 30 | - name: Run update 31 | env: 32 | NR_MONGO_URL: ${{ secrets.NR_MONGO_URL }} 33 | FLOW_ENV: PRODUCTION 34 | run: ./flow-library/.github/scripts/generate-catalog.sh 35 | 36 | -------------------------------------------------------------------------------- /.github/workflows/downloadStats.yml: -------------------------------------------------------------------------------- 1 | name: Update Download Stats 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '33 4 * * *' 7 | jobs: 8 | generate: 9 | name: 'Update Download Stats' 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out flow-library repository 13 | uses: actions/checkout@v2 14 | - uses: actions/setup-node@v1 15 | with: 16 | node-version: '18' 17 | - run: npm install --only=production 18 | - name: Run update 19 | env: 20 | NR_MONGO_URL: ${{ secrets.NR_MONGO_URL }} 21 | FLOW_ENV: PRODUCTION 22 | run: node ./tasks/update-download-stats.js 23 | 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | bin 3 | data 4 | node_modules 5 | settings*.js 6 | npm-debug.log 7 | logs/*access.log 8 | -------------------------------------------------------------------------------- /.nodemonignore: -------------------------------------------------------------------------------- 1 | data/* 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "attach", 10 | "restart": true, 11 | "sourceMaps": false, 12 | "name": "Attach", 13 | "address": "localhost", 14 | "port": 5858, 15 | "localRoot": "${workspaceFolder}", 16 | "remoteRoot": "/flow-library" 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine AS base 2 | 3 | # set working directory 4 | WORKDIR /flow-library 5 | 6 | # install git 7 | RUN apk add --no-cache git 8 | 9 | # copy project file 10 | COPY ./package.json ./ 11 | RUN npm install 12 | COPY . . 13 | CMD npm run dev 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node-RED Library 2 | 3 | This is the source of the application behind . 4 | 5 | It provides a searchable index of user-contributed flows as well as node packages 6 | published to npm. 7 | 8 | ## Running a development copy 9 | 10 | This repo comes with a docker-compose based development environment. To get started: 11 | 12 | 1. Install docker 13 | 14 | 2. Configure your flow-library settings. 15 | 16 | The repository includes the file `default-settings.js`. You should copy that 17 | to `settings.js` and update it with your various api keys. 18 | **Do not** check this file back into git - it has already been added to `.gitignore` to prevent this. 19 | 20 | 3. Run: 21 | 22 | npm run docker 23 | 24 | This first time you run this will take a while as it downloads various pieces. 25 | When it completes, you will be able to access http://localhost:8080 to see 26 | the flow library. 27 | 28 | The docker image uses `nodemon` to watch for changes to the source code and 29 | automatically restart the app when needed - without having to restart docker. 30 | 31 | ## Add nodes 32 | 33 | To test you will want to add a few nodes to the database. To do so, use the update-one task. For example. 34 | 35 | node tasks/update-one.js node-red-dashboard 36 | 37 | or in the docker container running the application 38 | 39 | docker exec -it flow-library_node_1 node tasks/update-one.js node-red-dashboard 40 | 41 | ## Configuration 42 | 43 | The following env vars are used to configure the app. 44 | 45 | ``` 46 | PORT 47 | NR_GITHUB_CLIENTID 48 | NR_GITHUB_SECRET 49 | NR_GITHUB_CALLBACK 50 | NR_GITHUB_ACCESSTOKEN 51 | NR_MONGO_URL 52 | NR_SESSION_KEY 53 | NR_SESSION_SECRET 54 | NR_ADMINS 55 | NR_TWITTER_CONSUMER_KEY 56 | NR_TWITTER_CONSUMER_SECRET 57 | NR_TWITTER_ACCESS_TOKEN_KEY 58 | NR_TWITTER_ACCESS_TOKEN_SECRET 59 | NR_MASTODON_URL 60 | NR_MASTODON_TOKEN 61 | NR_SLACK_WEBHOOK 62 | NR_MODULE_BLOCKLIST 63 | NR_AWS_BUCKET 64 | NR_AWS_ACCESS_KEY_ID 65 | NR_AWS_SECRET_ACCESS_KEY 66 | NR_AWS_REGION 67 | ``` 68 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please report any potential security issues to `team@nodered.org`. This will notify the core project team who will respond accordingly. 6 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const bodyParser = require('body-parser') 4 | const MongoStore = require('connect-mongo') 5 | const cookieParser = require('cookie-parser') 6 | const express = require('express') 7 | const { rateLimit } = require('express-rate-limit') 8 | const session = require('express-session') 9 | const mustache = require('mustache') 10 | const serveStatic = require('serve-static') 11 | 12 | const settings = require('./config') 13 | const db = require('./lib/db') 14 | const templates = require('./lib/templates') 15 | 16 | const limiter = rateLimit({ 17 | windowMs: 5 * 60 * 1000, // 5 minutes 18 | max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes) 19 | standardHeaders: false, // Return rate limit info in the `RateLimit-*` headers 20 | legacyHeaders: false, // Disable the `X-RateLimit-*` headers 21 | handler: (req, res, next, options) => { 22 | console.log(`Rate Limit: ${req.method} ${req.url} ${req.ip} `) 23 | res.status(options.statusCode).send(options.message) 24 | } 25 | }) 26 | 27 | ;(async function () { 28 | await db.init() 29 | const app = express() 30 | 31 | app.use(cookieParser()) 32 | if (!settings.maintenance) { 33 | app.use(session({ 34 | store: MongoStore.create({ 35 | mongoUrl: settings.mongo.url, 36 | touchAfter: 24 * 3600, 37 | collectionName: settings.session.collection || 'sessions_new' 38 | }), 39 | key: settings.session.key, 40 | secret: settings.session.secret, 41 | saveUninitialized: false, 42 | resave: false 43 | })) 44 | app.use(bodyParser.json()) 45 | app.use(bodyParser.urlencoded({ extended: true })) 46 | } 47 | 48 | app.use('/', serveStatic(path.join(__dirname, 'public'))) 49 | if (process.env.FLOW_ENV !== 'PRODUCTION') { 50 | app.use('*', function (req, res, next) { 51 | console.log('>', req.url) 52 | next() 53 | }) 54 | } 55 | 56 | app.use(limiter) 57 | 58 | if (!settings.maintenance) { 59 | app.set('trust proxy', 1) 60 | app.use(require('./routes/index')) 61 | app.use(require('./routes/auth')) 62 | app.use(require('./routes/flows')) 63 | app.use(require('./routes/nodes')) 64 | app.use(require('./routes/admin')) 65 | app.use(require('./routes/users')) 66 | app.use(require('./routes/api')) 67 | app.use(require('./routes/collections')) 68 | app.use(require('./routes/categories')) 69 | app.use(function (err, req, res, next) { 70 | if (err.code !== 'EBADCSRFTOKEN') { 71 | console.log('here', err) 72 | return next(err) 73 | } 74 | // handle CSRF token errors here 75 | res.status(403) 76 | res.send('Invalid request') 77 | let stringBody = '' 78 | if (req.method === 'POST') { 79 | stringBody = req.body 80 | if (typeof req.body === 'object') { 81 | try { 82 | stringBody = JSON.stringify(req.body) 83 | } catch (err) { 84 | } 85 | } 86 | if (typeof stringBody !== 'string') { 87 | stringBody = '' + stringBody 88 | } 89 | if (stringBody.length > 30) { 90 | const l = stringBody.length 91 | stringBody = stringBody.substring(0, 30) + `...[length:${l}]` 92 | } 93 | } 94 | console.log(`CSRF Error: ${req.method} ${req.url} ${req.ip} ${stringBody} `) 95 | }) 96 | app.use(function (req, res) { 97 | // We see lots of requests to these paths that we don't want to flood 98 | // the logs with so we missing more interesting things 99 | if (!/^\/(js|flow|node|css|font|jquery|images|font-awesome)\/?$/i.test(req.url)) { 100 | console.log(`404: ${req.method} ${req.url} ${req.ip}`) 101 | } 102 | res.status(404).send(mustache.render(templates['404'], { sessionuser: req.session.user }, templates.partials)) 103 | }) 104 | } else { 105 | app.use(function (req, res) { 106 | res.send(mustache.render(templates.maintenance, {}, templates.partials)) 107 | }) 108 | } 109 | app.listen(settings.port || 20982) 110 | console.log(`Listening on http://localhost:${settings.port || 20982}`) 111 | if (process.env.FLOW_ENV === 'PRODUCTION') { 112 | require('./lib/events').add({ 113 | action: 'started', 114 | message: 'Flow Library app started' 115 | }) 116 | } 117 | })() 118 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | try { 2 | // eslint-disable-next-line n/no-unpublished-require 3 | module.exports = require('../settings.js') 4 | } catch (err) { 5 | module.exports = require('../default-settings.js') 6 | if (process.env.NR_MAINTENANCE !== undefined) { 7 | module.exports.maintenance = (process.env.NR_MAINTENANCE === 'true') 8 | } 9 | module.exports.port = process.env.PORT || module.exports.port 10 | module.exports.github.clientId = process.env.NR_GITHUB_CLIENTID || module.exports.github.clientId 11 | module.exports.github.secret = process.env.NR_GITHUB_SECRET || module.exports.github.secret 12 | module.exports.github.authCallback = process.env.NR_GITHUB_CALLBACK || module.exports.github.authCallback 13 | module.exports.github.accessToken = process.env.NR_GITHUB_ACCESS_TOKEN || module.exports.github.accessToken 14 | module.exports.mongo.url = process.env.NR_MONGO_URL || module.exports.mongo.url 15 | module.exports.session.key = process.env.NR_SESSION_KEY || module.exports.session.key 16 | module.exports.session.secret = process.env.NR_SESSION_SECRET || module.exports.session.secret 17 | if (process.env.NR_ADMINS) { 18 | module.exports.admins = process.env.NR_ADMINS.split(',').map(t => t.trim()) 19 | } else { 20 | module.exports.admins = [] 21 | } 22 | if (process.env.NR_MODERATORS) { 23 | module.exports.moderators = process.env.NR_MODS.split(',').map(t => t.trim()) 24 | } else { 25 | module.exports.moderators = [] 26 | } 27 | 28 | module.exports.mastodon.url = process.env.NR_MASTODON_URL || module.exports.mastodon.url 29 | module.exports.mastodon.token = process.env.NR_MASTODON_TOKEN || module.exports.mastodon.token 30 | 31 | module.exports.slack.webhook = process.env.NR_SLACK_WEBHOOK || module.exports.slack.webhook 32 | 33 | if (process.env.NR_MODULE_BLOCKLIST) { 34 | module.exports.modules.block = process.env.NR_MODULE_BLOCKLIST.split(',').map(t => t.trim()) 35 | } 36 | module.exports.aws.iconBucket = process.env.NR_AWS_BUCKET || module.exports.aws.iconBucket 37 | module.exports.aws.accessKeyId = process.env.NR_AWS_ACCESS_KEY_ID || module.exports.aws.accessKeyId 38 | module.exports.aws.secretAccessKey = process.env.NR_AWS_SECRET_ACCESS_KEY || module.exports.aws.secretAccessKey 39 | module.exports.aws.region = process.env.NR_AWS_REGION || module.exports.aws.region 40 | } 41 | -------------------------------------------------------------------------------- /default-settings.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | var settings = { 4 | port: 8080, 5 | github: { 6 | clientId: "", 7 | secret: "", 8 | authCallback: "http://localhost:8080/login/callback", 9 | accessToken: "" 10 | }, 11 | mongo: { 12 | url: 'mongodb://mongo/flows' 13 | }, 14 | session: { 15 | key: 'nr.sid', 16 | secret: 'giraffe' 17 | }, 18 | admins: ["knolleary","dceejay"], 19 | mastodon: { 20 | url: '', 21 | token: '' 22 | }, 23 | slack: { 24 | webhook: '' 25 | }, 26 | modules: { 27 | block: [] 28 | }, 29 | aws: { 30 | iconBucket: "", 31 | accessKeyId: "", 32 | secretAccessKey: "", 33 | region: "" 34 | } 35 | }; 36 | 37 | module.exports = settings; -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | 5 | node: 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | volumes: 10 | - /flow-library/node_modules # prevent them being overwritten by the above 11 | - .:/flow-library 12 | ports: 13 | - "8080:8080" 14 | - "5858:5858" 15 | links: 16 | - mongo 17 | environment: 18 | NODE_ENV: development 19 | mongo: 20 | image: mongo 21 | volumes: 22 | - mongo-data:/data/db 23 | ports: 24 | - '27017:27017' 25 | 26 | volumes: 27 | mongo-data: 28 | -------------------------------------------------------------------------------- /lib/aws.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | const AWS = require('aws-sdk') 4 | 5 | const settings = require('../config') 6 | 7 | AWS.config.update(settings.aws) 8 | 9 | const s3 = new AWS.S3({ apiVersion: '2006-03-01' }) 10 | 11 | async function upload (pathToFile, fileName) { 12 | return new Promise((resolve, reject) => { 13 | const uploadParams = { Bucket: settings.aws.iconBucket, Key: '', Body: '' } 14 | 15 | if (/\.svg$/i.test(pathToFile)) { 16 | uploadParams.ContentType = 'image/svg+xml' 17 | } else if (/\.png$/i.test(pathToFile)) { 18 | uploadParams.ContentType = 'image/png' 19 | } else if (/\.jpg/i.test(pathToFile)) { 20 | uploadParams.ContentType = 'image/jpeg' 21 | } else if (/\.gif/i.test(pathToFile)) { 22 | uploadParams.ContentType = 'image/gif' 23 | } 24 | 25 | const fileStream = fs.createReadStream(pathToFile) 26 | fileStream.on('error', function (err) { 27 | reject(err) 28 | }) 29 | uploadParams.Body = fileStream 30 | uploadParams.Key = fileName 31 | s3.upload(uploadParams, function (err, data) { 32 | if (err) { 33 | reject(err) 34 | } if (data) { 35 | resolve(data.Location) 36 | } 37 | }) 38 | }) 39 | } 40 | 41 | module.exports = { 42 | upload 43 | } 44 | -------------------------------------------------------------------------------- /lib/categories.js: -------------------------------------------------------------------------------- 1 | const db = require('./db') 2 | const { generateSummary } = require('./utils') 3 | 4 | // Given they are largely static and there are not many, we can cache the category list 5 | // to save hitting the DB for every page view 6 | let categoryCache 7 | 8 | async function refreshCategoryCache () { 9 | categoryCache = await db.categories.find().toArray() 10 | categoryCache.sort((a, b) => a.name.localeCompare(b.name)) 11 | } 12 | 13 | function normaliseName (name) { 14 | return name.toLowerCase().replace(/[ /]+/g, '-').replace(/&/g, 'and') 15 | } 16 | 17 | async function createCategory (category) { 18 | category._id = normaliseName(category.name) 19 | category.updated_at = (new Date()).toISOString() 20 | category.summary = generateSummary(category.description) 21 | await db.categories.insertOne(category, { upsert: true }) 22 | await refreshCategoryCache() 23 | return category._id 24 | } 25 | 26 | // async function removeCollection (id) { 27 | // const collection = await getCollection(id) 28 | // const tags = collection.tags || [] 29 | // const promises = [] 30 | // for (let i = 0; i < tags.length; i++) { 31 | // promises.push(db.tags.updateOne({ _id: tags[i] }, { $inc: { count: -1 } })) 32 | // } 33 | // promises.push(db.tags.deleteMany({ count: { $lte: 0 } })) 34 | // await Promise.all(promises) 35 | // try { 36 | // await db.flows.deleteOne({ _id: id }) 37 | // } finally { 38 | // view.resetTypeCountCache() 39 | // } 40 | // } 41 | 42 | async function getCategories () { 43 | if (!categoryCache) { 44 | await refreshCategoryCache() 45 | } 46 | return categoryCache 47 | } 48 | async function getCategory (id) { 49 | const data = await db.categories.find({ _id: id }).toArray() 50 | if (!data || data.length === 0) { 51 | throw new Error(`Category ${id} not found`) 52 | } 53 | return data[0] 54 | } 55 | 56 | async function updateCategory (category) { 57 | category.updated_at = (new Date()).toISOString() 58 | if (Object.prototype.hasOwnProperty.call(category, 'description')) { 59 | category.summary = generateSummary(category.description) 60 | } 61 | try { 62 | await db.categories.updateOne( 63 | { _id: category._id }, 64 | { $set: category } 65 | ) 66 | } catch (err) { 67 | console.log('Update category', category._id, 'ERR', err.toString()) 68 | throw err 69 | } 70 | await refreshCategoryCache() 71 | return category._id 72 | } 73 | 74 | module.exports = { 75 | getAll: getCategories, 76 | create: createCategory, 77 | get: getCategory, 78 | update: updateCategory 79 | } 80 | -------------------------------------------------------------------------------- /lib/collections.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto') 2 | 3 | const db = require('./db') 4 | const users = require('./users') 5 | const { generateSummary } = require('./utils') 6 | const view = require('./view') 7 | 8 | async function createCollection (collection) { 9 | const collectionID = crypto.randomBytes(9).toString('base64').replace(/\//g, '-').replace(/\+/g, '_') 10 | const tags = collection.tags || [] 11 | for (let i = 0; i < tags.length; i++) { 12 | await db.tags.updateOne({ _id: tags[i] }, { $inc: { count: 1 } }, { upsert: true }) 13 | } 14 | collection.type = 'collection' 15 | collection._id = collectionID 16 | collection.updated_at = (new Date()).toISOString() 17 | collection.summary = generateSummary(collection.description) 18 | try { 19 | await db.flows.replaceOne({ _id: collectionID }, collection, { upsert: true }) 20 | } finally { 21 | view.resetTypeCountCache() 22 | } 23 | return collectionID 24 | } 25 | 26 | async function removeCollection (id) { 27 | const collection = await getCollection(id) 28 | const tags = collection.tags || [] 29 | const promises = [] 30 | for (let i = 0; i < tags.length; i++) { 31 | promises.push(db.tags.updateOne({ _id: tags[i] }, { $inc: { count: -1 } })) 32 | } 33 | promises.push(db.tags.deleteMany({ count: { $lte: 0 } })) 34 | await Promise.all(promises) 35 | try { 36 | await db.flows.deleteOne({ _id: id }) 37 | } finally { 38 | view.resetTypeCountCache() 39 | } 40 | } 41 | 42 | async function getCollection (id) { 43 | const data = await db.flows.find({ _id: id }).toArray() 44 | if (!data || data.length === 0) { 45 | throw new Error(`Collection ${id} not found`) 46 | } 47 | return data[0] 48 | } 49 | 50 | async function updateCollection (collection) { 51 | delete collection.type 52 | collection.updated_at = (new Date()).toISOString() 53 | const errors = {} 54 | if (Object.prototype.hasOwnProperty.call(collection, 'name')) { 55 | if (collection.name.trim().length < 10) { 56 | errors.name = 'Must be at least 10 characters' 57 | } 58 | } 59 | if (Object.prototype.hasOwnProperty.call(collection, 'description')) { 60 | if (collection.description.trim().length < 30) { 61 | errors.description = 'Must be at least 30 characters' 62 | } 63 | collection.summary = generateSummary(collection.description) 64 | } 65 | if (Object.prototype.hasOwnProperty.call(collection, 'gitOwners')) { 66 | const unmatched = await users.checkAllExist(collection.gitOwners) 67 | if (unmatched && unmatched.length > 0) { 68 | errors.owners = unmatched 69 | } 70 | } 71 | if (Object.keys(errors).length > 0) { 72 | throw errors 73 | } 74 | try { 75 | await db.flows.updateOne( 76 | { _id: collection._id }, 77 | { $set: collection } 78 | ) 79 | } catch (err) { 80 | console.log('Update collection', collection._id, 'ERR', err.toString()) 81 | throw err 82 | } 83 | return collection._id 84 | } 85 | 86 | async function addItem (collectionId, itemId) { 87 | try { 88 | await db.flows.updateOne( 89 | { _id: collectionId }, 90 | { $addToSet: { items: itemId } } 91 | ) 92 | } catch (err) { 93 | console.log('Adding collection item', collectionId, itemId, 'ERR', err.toString()) 94 | throw err 95 | } 96 | return collectionId 97 | } 98 | 99 | async function removeItem (collectionId, itemId) { 100 | try { 101 | await db.flows.updateOne( 102 | { _id: collectionId }, 103 | { $pull: { items: itemId } } 104 | ) 105 | } catch (err) { 106 | console.log('Remove collection item', collectionId, itemId, 'ERR', err.toString()) 107 | throw err 108 | } 109 | return collectionId 110 | } 111 | 112 | async function getSiblings (collectionId, itemId) { 113 | const docs = db.flows.aggregate([ 114 | { $match: { _id: collectionId } }, 115 | { 116 | $project: { 117 | name: 1, 118 | items: 1, 119 | index: { $indexOfArray: ['$items', itemId] } 120 | } 121 | }, 122 | { 123 | $project: { 124 | name: 1, 125 | items: 1, 126 | prevIndex: { $subtract: ['$index', 1] }, 127 | nextIndex: { $add: ['$index', 1] } 128 | } 129 | }, 130 | { 131 | $project: { 132 | name: 1, 133 | prev: { $cond: { if: { $gte: ['$prevIndex', 0] }, then: { $arrayElemAt: ['$items', '$prevIndex'] }, else: '' } }, 134 | next: { $arrayElemAt: ['$items', '$nextIndex'] } 135 | } 136 | } 137 | ]).toArray() 138 | 139 | if (docs && docs.length > 0) { 140 | docs[0].prevType = await view.getThingType(docs[0].prev) 141 | docs[0].nextType = await view.getThingType(docs[0].next) 142 | } else { 143 | return docs 144 | } 145 | } 146 | 147 | module.exports = { 148 | get: getCollection, 149 | update: updateCollection, 150 | remove: removeCollection, 151 | create: createCollection, 152 | addItem, 153 | removeItem, 154 | getSiblings 155 | } 156 | -------------------------------------------------------------------------------- /lib/db.js: -------------------------------------------------------------------------------- 1 | const { MongoClient } = require('mongodb') 2 | 3 | const settings = require('../config') 4 | 5 | const api = { 6 | init 7 | } 8 | 9 | async function init () { 10 | const collections = ['flows', 'nodes', 'users', 'tags', 'events', 'ratings', 'categories'] 11 | const client = new MongoClient(settings.mongo.url) 12 | await client.connect() 13 | const db = client.db() 14 | await db.command({ ping: 1 }) 15 | 16 | api.close = async function () { 17 | return client.close() 18 | } 19 | 20 | collections.forEach(col => { 21 | api[col] = db[col] = db.collection(col) 22 | }) 23 | await db.flows.createIndex({ updated_at: -1 }) 24 | await db.flows.createIndex({ keywords: 1 }) 25 | await db.flows.createIndex({ 'maintainers.name': 1 }) 26 | await db.flows.createIndex({ npmOwners: 1 }) 27 | await db.flows.createIndex({ gitOwners: 1 }) 28 | await db.flows.createIndex({ 'rating.score': -1, 'rating.count': -1 }) 29 | await db.flows.createIndex({ 'downloads.week': -1 }) 30 | 31 | await db.ratings.createIndex({ module: 1 }) 32 | await db.ratings.createIndex({ user: 1, module: 1 }) 33 | } 34 | 35 | // if (process.env.FLOW_ENV !== "PRODUCTION") { 36 | // collections.forEach(col => { 37 | // var collection = db[col]; 38 | // for (var x in collection) { 39 | // if (typeof collection[x] === 'function' && !/^_/.test(x)) { 40 | // db[col]["__"+x] = db[col][x]; 41 | // let origFunc = db[col][x]; 42 | // let signature = col+"."+x; 43 | // db[col][x] = function() { 44 | // console.log(" ",signature);//arguments[0]); 45 | // return origFunc.apply(db[col],arguments); 46 | // } 47 | // } 48 | // } 49 | // }) 50 | // } 51 | 52 | module.exports = api 53 | -------------------------------------------------------------------------------- /lib/events.js: -------------------------------------------------------------------------------- 1 | const request = require('request') 2 | 3 | const settings = require('../config') 4 | 5 | const db = require('./db') 6 | 7 | const icons = { 8 | module_report: ':warning: Report submitted: ', 9 | update: ':rocket: Updated: ', 10 | refresh_requested: ':mag: Refresh requested: ', 11 | error: ':boom: Error: ', 12 | reject: ':-1: Rejected module: ', 13 | remove: ':wastebasket: Removed module: ', 14 | started: ':coffee: Flow Library restarted', 15 | scorecard_added: ':clipboard: Scorecard: ', 16 | scorecard_failed: ':warning: Scorecard failed: ' 17 | } 18 | const colors = { 19 | module_report: 'warning', 20 | update: 'good', 21 | refresh_requested: 'good', 22 | error: 'danger', 23 | reject: 'danger', 24 | remove: 'danger', 25 | scorecard_added: 'good', 26 | scorecard_failed: 'danger' 27 | 28 | } 29 | 30 | async function addEvent (event) { 31 | event.ts = Date.now() 32 | // console.log(JSON.stringify(event)); 33 | try { 34 | await db.events.insertOne(event) 35 | } catch (err) { 36 | console.error('Error adding event', err.toString()) 37 | } 38 | if (settings.slack && settings.slack.webhook) { 39 | try { 40 | let msg = icons[event.action] || '' 41 | 42 | if (event.module) { 43 | if (event.action === 'scorecard_added') { 44 | msg += ' ' 45 | } else { 46 | msg += ' ' 47 | } 48 | } else if (event.action !== 'started') { 49 | msg = 'Flow library error' 50 | } 51 | 52 | if (event.version) { 53 | msg += ' (' + event.version + ')' 54 | } 55 | if (event.user) { 56 | msg += ' User: ' + '' 57 | } 58 | const json = { 59 | attachments: [ 60 | { 61 | color: colors[event.action] || '#999999', 62 | fallback: msg, 63 | pretext: msg, 64 | fields: [] 65 | } 66 | ] 67 | } 68 | 69 | if (Object.prototype.hasOwnProperty.call(event, 'message') && event.action !== 'started') { 70 | json.attachments[0].text = ('' + event.message).replace(/&/g, '&').replace(//g, '>') 71 | } 72 | const options = { 73 | url: settings.slack.webhook, 74 | method: 'POST', 75 | json 76 | } 77 | request(options, function (error, resp, body) { 78 | if (error) { 79 | console.log(error) 80 | } 81 | }) 82 | } catch (err2) { 83 | console.log(err2) 84 | } 85 | } else { 86 | console.log('Event:', JSON.stringify(event)) 87 | } 88 | } 89 | 90 | async function getEvents () { 91 | // Return last 50 events... 92 | const docs = await db.events.find({}).sort({ ts: -1 }).limit(50).toArray() 93 | docs.forEach(d => { 94 | d.time = (new Date(d.ts)).toISOString() 95 | }) 96 | } 97 | 98 | module.exports = { 99 | add: addEvent, 100 | get: getEvents 101 | } 102 | -------------------------------------------------------------------------------- /lib/gists.js: -------------------------------------------------------------------------------- 1 | const db = require('./db') 2 | const github = require('./github') 3 | const users = require('./users') 4 | const view = require('./view') 5 | 6 | async function getGist (id, projection) { 7 | projection = projection || {} 8 | return db.flows.findOne({ _id: id }, projection) 9 | } 10 | 11 | async function refreshGist (id) { 12 | console.log(`Request to refresh gist ${id}`) 13 | const gist = await getGist(id, { etag: 1, tags: 1, added_at: 1 }) 14 | if (!gist) { 15 | const err = new Error('not_found') 16 | err.code = 404 17 | throw err 18 | } 19 | const etag = process.env.FORCE_UPDATE ? null : gist.etag 20 | console.log(` - using etag ${etag}`) 21 | try { 22 | const data = await github.getGist(id, etag) 23 | if (data == null) { 24 | console.log(' - github returned null') 25 | // no update needed 26 | await db.flows.updateOne({ _id: id }, { $set: { refreshed_at: Date.now() } }) 27 | return null 28 | } else { 29 | data.added_at = gist.added_at 30 | data.tags = gist.tags 31 | data.type = 'flow' 32 | return addGist(data) 33 | } 34 | } catch (err) { 35 | console.log(` - error during refresh - removing gist: ${err.toString()}`) 36 | await removeGist(id) 37 | throw err 38 | } 39 | } 40 | 41 | async function createGist (accessToken, gist, tags) { 42 | try { 43 | const data = await github.createGist(gist, accessToken) 44 | for (let i = 0; i < tags.length; i++) { 45 | db.tags.updateOne({ _id: tags[i] }, { $inc: { count: 1 } }, { upsert: true }) 46 | } 47 | data.added_at = Date.now() 48 | data.tags = tags 49 | data.type = 'flow' 50 | return addGist(data) 51 | } catch (err) { 52 | console.log('ERROR createGist', err) 53 | throw err 54 | } 55 | } 56 | 57 | function generateSummary (desc) { 58 | let summary = (desc || '').split('\n')[0] 59 | const re = /!?\[(.*?)\]\(.*?\)/g 60 | let m 61 | while ((m = re.exec(summary)) !== null) { 62 | summary = summary.substring(0, m.index) + m[1] + summary.substring(m.index + m[0].length) 63 | } 64 | 65 | if (summary.length > 150) { 66 | summary = summary.substring(0, 150).split('\n')[0] + '...' 67 | } 68 | return summary 69 | } 70 | 71 | async function addGist (data) { 72 | const originalFiles = data.files 73 | if (!originalFiles['flow.json']) { 74 | throw new Error('Missing file flow.json') 75 | } 76 | if (originalFiles['flow.json'].truncated) { 77 | if (originalFiles['flow.json'].size < 300000) { 78 | originalFiles['flow.json'].content = await github.getGistFile(originalFiles['flow.json'].raw_url) 79 | } else { 80 | throw new Error('Flow file too big') 81 | } 82 | } 83 | if (!originalFiles['README.md']) { 84 | throw new Error('Missing file README.md') 85 | } 86 | if (originalFiles['README.md'].truncated) { 87 | if (originalFiles['README.md'].size < 300000) { 88 | originalFiles['README.md'].content = await github.getGistFile(originalFiles['README.md'].raw_url) 89 | } else { 90 | throw new Error('README file too big') 91 | } 92 | } 93 | data.flow = originalFiles['flow.json'].content 94 | data.readme = originalFiles['README.md'].content 95 | data.summary = generateSummary(data.readme) 96 | delete data.files 97 | delete data.history 98 | data.gitOwners = [ 99 | data.owner.login 100 | ] 101 | 102 | delete data.rateLimit 103 | 104 | data.type = 'flow' 105 | data.refreshed_at = Date.now() 106 | data._id = data.id 107 | 108 | await db.flows.replaceOne({ _id: data._id }, data, { upsert: true }) 109 | 110 | await users.ensureExists(data.owner.login) 111 | 112 | view.resetTypeCountCache() 113 | return data.id 114 | } 115 | 116 | async function addGistById (id) { 117 | console.log('Add gist [', id, ']') 118 | const data = await github.getGist(id) 119 | data.added_at = Date.now() 120 | data.tags = [] 121 | view.resetTypeCountCache() 122 | return addGist(data) 123 | } 124 | 125 | async function removeGist (id) { 126 | const gist = await getGist(id) 127 | if (gist) { 128 | const promises = [] 129 | for (let i = 0; i < gist.tags.length; i++) { 130 | promises.push(db.tags.updateOne({ _id: gist.tags[i] }, { $inc: { count: -1 } })) 131 | } 132 | promises.push(db.tags.deleteMany({ count: { $lte: 0 } })) 133 | await Promise.all(promises) 134 | await db.flows.deleteOne({ _id: id }) 135 | view.resetTypeCountCache() 136 | } 137 | } 138 | 139 | async function getGists (query) { 140 | query.type = 'flow' 141 | return db.flows.find(query, { sort: { refreshed_at: -1 }, projection: { id: 1, description: 1, tags: 1, refreshed_at: 1, 'owner.login': true } }).toArray() 142 | } 143 | 144 | async function getGistsForUser (userId) { 145 | return getGists({ 'owner.login': userId }) 146 | } 147 | async function getGistsForTag (tag) { 148 | return getGists({ tags: tag }) 149 | } 150 | async function getAllGists () { 151 | return getGists({}) 152 | } 153 | 154 | async function getUser (id) { 155 | return db.users.findOne({ _id: id }) 156 | } 157 | 158 | async function updateTags (id, tags) { 159 | tags = tags || [] 160 | const gist = await getGist(id, { tags: 1, description: 1, 'files.README-md': 1, 'owner.login': 1 }) 161 | if (!gist) { 162 | const err = new Error('not_found') 163 | err.code = 404 164 | throw err 165 | } 166 | 167 | const oldTags = gist.tags 168 | 169 | if (oldTags.length === tags.length) { 170 | let matches = true 171 | for (let i = 0; i < oldTags.length; i++) { 172 | if (tags.indexOf(oldTags[i]) === -1) { 173 | matches = false 174 | break 175 | } 176 | } 177 | if (matches) { 178 | return 179 | } 180 | } 181 | const promises = [] 182 | 183 | for (let i = 0; i < oldTags.length; i++) { 184 | if (tags.indexOf(oldTags[i]) === -1) { 185 | promises.push(db.tags.updateOne({ _id: oldTags[i] }, { $inc: { count: -1 } })) 186 | } 187 | } 188 | for (let i = 0; i < tags.length; i++) { 189 | if (oldTags.indexOf(tags[i]) === -1) { 190 | promises.push(db.tags.updateOne({ _id: tags[i] }, { $inc: { count: 1 } }, { upsert: true })) 191 | } 192 | } 193 | promises.push(db.tags.deleteMany({ count: { $lte: 0 } })) 194 | promises.push(db.flows.updateOne({ _id: id }, { $set: { tags } })) 195 | return Promise.all(promises) 196 | } 197 | 198 | async function getTags (query) { 199 | return db.tags.find(query, { sort: { count: -1, _id: 1 } }).toArray() 200 | } 201 | 202 | function getAllTags () { 203 | return getTags({}) 204 | } 205 | 206 | module.exports = { 207 | add: addGistById, 208 | refresh: refreshGist, 209 | remove: removeGist, 210 | updateTags, 211 | get: getGist, 212 | getAll: getAllGists, 213 | getGists, 214 | getForUser: getGistsForUser, 215 | getUser, 216 | create: createGist, 217 | getAllTags, 218 | getForTag: getGistsForTag 219 | } 220 | 221 | // var repo = "https://gist.github.com/6c3b201624588e243f82.git"; 222 | // var sys = require('sys'); 223 | // var exec = require('child_process').exec; 224 | // function puts(error, stdout, stderr) { sys.puts(stdout); sys.puts(stderr); } 225 | // exec("git clone "+repo, puts); 226 | // 227 | -------------------------------------------------------------------------------- /lib/github.js: -------------------------------------------------------------------------------- 1 | const https = require('https') 2 | 3 | const settings = require('../config') 4 | const defaultAccessToken = settings.github.accessToken 5 | 6 | function send (opts) { 7 | return new Promise((resolve, reject) => { 8 | const accessToken = opts.accessToken || defaultAccessToken 9 | const method = (opts.method || 'GET').toUpperCase() 10 | const path = opts.path 11 | const headers = opts.headers || {} 12 | const body = opts.body 13 | 14 | const _headers = { 15 | 'user-agent': 'node-red', 16 | accept: 'application/vnd.github.v3', 17 | authorization: 'token ' + accessToken 18 | } 19 | if (body) { 20 | _headers['content-type'] = 'application/json' 21 | } 22 | for (const h in headers) { 23 | _headers[h] = headers[h] 24 | } 25 | const options = { 26 | host: 'api.github.com', 27 | port: 443, 28 | path, 29 | method, 30 | headers: _headers 31 | } 32 | // console.log("---------------"); 33 | // console.log(options); 34 | // console.log("---------------"); 35 | const req = https.request(options, function (res) { 36 | res.setEncoding('utf8') 37 | let data = '' 38 | res.on('data', function (chunk) { 39 | data += chunk 40 | }) 41 | res.on('end', function () { 42 | if (/^application\/json/.test(res.headers['content-type'])) { 43 | data = JSON.parse(data) 44 | data.etag = res.headers.etag 45 | data.rateLimit = { 46 | limit: res.headers['x-ratelimit-limit'], 47 | remaining: res.headers['x-ratelimit-remaining'], 48 | reset: res.headers['x-ratelimit-reset'] 49 | } 50 | } 51 | resolve({ statusCode: res.statusCode, headers: res.headers, data }) 52 | }) 53 | }) 54 | req.on('error', function (e) { 55 | console.log('problem with request: ' + e.message) 56 | reject(e) 57 | }) 58 | 59 | if (body) { 60 | req.write(JSON.stringify(body) + '\n') 61 | } 62 | req.end() 63 | }) 64 | } 65 | 66 | function getSimple (path, lastEtag) { 67 | return new Promise((resolve, reject) => { 68 | const headers = {} 69 | if (lastEtag) { 70 | headers['If-None-Match'] = lastEtag 71 | } 72 | console.log('github.getSimple', path) 73 | send({ path, headers }).then(function (result) { 74 | if (lastEtag && result.statusCode === 304) { 75 | resolve(null) 76 | return null 77 | } else if (result.statusCode === 404) { 78 | reject(result) 79 | return null 80 | } else { 81 | resolve(result.data) 82 | return null 83 | } 84 | }).catch(function (er) { reject(er) }) 85 | }) 86 | } 87 | 88 | function getGistFile (fileUrl) { 89 | return new Promise((resolve, reject) => { 90 | const req = https.get(fileUrl, function (res) { 91 | res.setEncoding('utf8') 92 | let data = '' 93 | res.on('data', function (chunk) { 94 | data += chunk 95 | }) 96 | res.on('end', function () { 97 | resolve(data) 98 | }) 99 | }) 100 | req.on('error', function (e) { 101 | console.log('problem with request: ' + e.message) 102 | reject(e) 103 | }) 104 | req.end() 105 | }) 106 | } 107 | 108 | module.exports = { 109 | getGistFile, 110 | getAuthedUser: function (accessToken) { 111 | return new Promise((resolve, reject) => { 112 | send({ path: '/user', accessToken }).then(function (result) { 113 | resolve(result.data) 114 | return null 115 | }).catch(function (er) { reject(er) }) 116 | }) 117 | }, 118 | getUser: function (user, lastEtag) { 119 | return getSimple('/users/' + user, lastEtag) 120 | }, 121 | 122 | getGist: function (id, lastEtag) { 123 | return getSimple('/gists/' + id, lastEtag) 124 | }, 125 | 126 | createGist: function (gistData, accessToken) { 127 | return new Promise((resolve, reject) => { 128 | send({ path: '/gists', method: 'POST', body: gistData, accessToken }).then(function (result) { 129 | resolve(result.data) 130 | return null 131 | }).catch(function (er) { reject(er) }) 132 | }) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /lib/nodes.js: -------------------------------------------------------------------------------- 1 | const db = require('./db') 2 | 3 | const CORE_NODES = ['inject', 'debug', 'complete', 'catch', 'status', 'link in', 'link out', 'link call', 'comment', 'unknown', 'function', 'switch', 'change', 'range', 'template', 'delay', 'trigger', 'exec', 'rbe', 'tls-config', 'http proxy', 'mqtt in', 'mqtt out', 'mqtt-broker', 'http in', 'http response', 'http request', 'websocket in', 'websocket out', 'websocket-listener', 'websocket-client', 'tcp in', 'tcp out', 'tcp request', 'udp in', 'udp out', 'csv', 'html', 'json', 'xml', 'yaml', 'split', 'join', 'sort', 'batch', 'file', 'file in', 'watch'].reduce(function (o, v, i) { 4 | o[v] = 1 5 | return o 6 | }, {}) 7 | 8 | async function saveToDb (info) { 9 | try { 10 | if (info) { 11 | info.type = 'node' 12 | info.updated_at = info.time.modified 13 | info.npmOwners = info.maintainers.map(function (m) { return m.name }) 14 | console.log('saveToDb update', info._id) 15 | await db.flows.updateOne( 16 | { _id: info._id }, 17 | { $set: info }, 18 | { upsert: true } 19 | ) 20 | return info._id + ' (' + info['dist-tags'].latest + ')' 21 | } else { 22 | // If the module was already downloaded, then this will get passed 23 | // null. Had it rejected, we would delete the module. 24 | } 25 | } catch (err) { 26 | console.log('!!!! saveToDb err', err) 27 | throw err 28 | } 29 | } 30 | 31 | async function update (id, info) { 32 | return db.flows.updateOne({ _id: id }, { $set: info }, {}) 33 | } 34 | 35 | function removeFromDb (id) { 36 | return db.flows.deleteOne({ _id: id }) 37 | } 38 | 39 | async function get (name, projection) { 40 | let query = {} 41 | let proj = {} 42 | // var proj = { 43 | // name:1, 44 | // description:1, 45 | // "dist-tags":1, 46 | // time:1, 47 | // author:1, 48 | // keywords:1 49 | // }; 50 | if (typeof name === 'object') { 51 | proj = name 52 | } else if (typeof name === 'string') { 53 | query = { _id: name } 54 | if (typeof projection === 'object') { 55 | proj = projection 56 | } 57 | } 58 | 59 | query.type = 'node' 60 | 61 | const docs = await db.flows.find(query, { projection: proj }).sort({ 'time.modified': 1 }).toArray() 62 | if (query._id) { 63 | if (!docs[0]) { 64 | const err = new Error('node not found:' + name) 65 | err.code = 'NODE_NOT_FOUND' 66 | throw err 67 | } else { 68 | if (docs[0].versions) { 69 | docs[0].versions.latest = JSON.parse(docs[0].versions.latest) 70 | } 71 | return docs[0] 72 | } 73 | } else { 74 | return docs 75 | } 76 | } 77 | async function findTypes (types) { 78 | if (types.length === 0) { 79 | return {} 80 | } else { 81 | const query = types.map(function (t) { 82 | return { types: t } 83 | }) 84 | const result = {} 85 | const docs = await db.flows.find({ type: 'node', $or: query }, { _id: 1, types: 1 }).toArray() 86 | docs.forEach(function (d) { 87 | d.types.forEach(function (t) { 88 | try { 89 | result[t] = result[t] || [] 90 | result[t].push(d._id) 91 | } catch (err) { 92 | console.log('Unexpected error lib/nodes.findTypes', err) 93 | console.log(' - known types:', Object.keys(t)) 94 | console.log(' - trying to add:', t) 95 | console.log(' - from:', d._id) 96 | } 97 | }) 98 | }) 99 | return result 100 | } 101 | } 102 | 103 | async function getLastUpdateTime (name) { 104 | const query = { type: 'node' } 105 | if (name) { 106 | query._id = name 107 | } 108 | const docs = await db.flows.find(query, { projection: { _id: 1, 'time.modified': 1, updated_at: 1 } }).sort({ 'time.modified': -1 }).limit(1).toArray() 109 | if (docs.length === 1) { 110 | // console.log(docs[0].updated_at) 111 | return (new Date(docs[0].updated_at)).getTime() 112 | } 113 | return 0 114 | } 115 | 116 | function getPopularByDownloads () { 117 | return db.flows.find({ type: 'node' }, { projection: { _id: 1, downloads: 1 } }) 118 | .sort({ 'downloads.week': -1 }) 119 | .limit(30) 120 | .toArray() 121 | } 122 | 123 | function getSummary () { 124 | return db.flows.find({ type: 'node' }, { projection: { _id: 1, downloads: 1, time: 1 } }).toArray() 125 | } 126 | 127 | module.exports = { 128 | CORE_NODES, 129 | save: saveToDb, 130 | remove: removeFromDb, 131 | update, 132 | close: async function () { return db.close() }, 133 | get, 134 | findTypes, 135 | getLastUpdateTime, 136 | getPopularByDownloads, 137 | getSummary 138 | } 139 | -------------------------------------------------------------------------------- /lib/ratings.js: -------------------------------------------------------------------------------- 1 | const db = require('./db') 2 | const events = require('./events') 3 | const npmNodes = require('./nodes') 4 | 5 | async function saveRating (thingId, user, rating) { 6 | await db.ratings.updateOne( 7 | { 8 | module: thingId, 9 | user 10 | }, 11 | { 12 | $set: { 13 | module: thingId, 14 | user, 15 | rating, 16 | time: new Date() 17 | } 18 | }, 19 | { upsert: true } 20 | ) 21 | } 22 | 23 | async function removeRating (thingId, user) { 24 | await db.ratings.deleteOne({ 25 | module: thingId, 26 | user 27 | }) 28 | } 29 | 30 | async function getModuleRating (npmModule) { 31 | const results = await db.ratings.aggregate( 32 | [ 33 | { $match: { module: npmModule } }, 34 | { 35 | $group: { _id: '$module', total: { $sum: '$rating' }, count: { $sum: 1 } } 36 | } 37 | ] 38 | ).toArray() 39 | console.log(results) 40 | if (results.length > 0) { 41 | return { 42 | module: npmModule, 43 | total: results[0].total, 44 | count: results[0].count 45 | } 46 | } 47 | } 48 | 49 | async function getForUser (npmModule, user) { 50 | return await db.ratings.findOne({ 51 | user, 52 | module: npmModule 53 | }) 54 | } 55 | 56 | async function removeForModule (npmModule) { 57 | return db.ratings.deleteOne({ module: npmModule }) 58 | } 59 | 60 | async function getRatedModules () { 61 | return db.ratings.distinct('module', {}) 62 | } 63 | 64 | async function rateThing (thingId, userId, rating) { 65 | try { 66 | rating = Number(rating) 67 | if (isNaN(rating) || rating === 0) { 68 | await removeRating(thingId, userId) 69 | await events.add({ 70 | action: 'module_rating', 71 | module: thingId, 72 | message: 'removed', 73 | user: userId 74 | }) 75 | } else { 76 | await saveRating(thingId, userId, rating) 77 | await events.add({ 78 | action: 'module_rating', 79 | module: thingId, 80 | message: rating, 81 | user: userId 82 | }) 83 | } 84 | const currentRating = await module.exports.get(thingId) 85 | let nodeRating = {} 86 | if (currentRating && currentRating.count > 0) { 87 | nodeRating = { 88 | score: currentRating.total / currentRating.count, 89 | count: currentRating.count 90 | } 91 | } 92 | return npmNodes.update(thingId, { rating: nodeRating }) 93 | } catch (err) { 94 | console.log('error rating node module: ' + thingId, err) 95 | } 96 | } 97 | 98 | module.exports = { 99 | rateThing, 100 | get: async function (thingId, user) { 101 | console.log('rate get', thingId, user) 102 | let rating = null 103 | const totalRatings = await getModuleRating(thingId) 104 | if (!totalRatings) { 105 | return null 106 | } 107 | rating = totalRatings 108 | const userRating = await getForUser(thingId, user) 109 | if (userRating) { 110 | rating.userRating = userRating 111 | } 112 | return rating 113 | }, 114 | getUserRating: getForUser, 115 | getRatedModules, 116 | removeForModule 117 | } 118 | -------------------------------------------------------------------------------- /lib/scorecard.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto') 2 | 3 | const fs = require('fs-extra') 4 | const nodereddev = require('node-red-dev') 5 | 6 | const events = require('./events') 7 | const npmNodes = require('./nodes') 8 | 9 | function scorecard (packagename, version, nodePath) { 10 | const fileid = crypto.randomBytes(4).toString('hex') 11 | console.log('Running scorecard', packagename, version, nodePath) 12 | try { 13 | const pkg = fs.readJsonSync(nodePath + '/package.json') 14 | console.log(' - Scorecard package.json:', pkg.name, pkg.version) 15 | } catch (err) { 16 | console.log(' - Error checking packaging:', err) 17 | } 18 | nodereddev.run(['validate', '-p', nodePath, '-o', `${nodePath}/../${fileid}.json`, '-e', 'true']) 19 | // eslint-disable-next-line n/no-extraneous-require 20 | .then(require('@oclif/command/flush')) 21 | .then(() => { 22 | const card = fs.readJsonSync(`${nodePath}/../${fileid}.json`) 23 | return npmNodes.update(packagename, { scorecard: card }).then(() => card) 24 | }).then((card) => { 25 | // fs.removeSync(nodePath+'/../..'); 26 | let message = 'Result: ' 27 | 28 | const keys = Object.keys(card) 29 | keys.sort(function (A, B) { 30 | if (A[0] !== B[0]) { 31 | // Reverse order of the groups - P, N, D 32 | return B.localeCompare(A) 33 | } else { 34 | // Numerical order within the group 35 | return A.substring(1).localeCompare(B.substring(1)) 36 | } 37 | }) 38 | 39 | for (const rule of keys) { 40 | if (rule !== 'package') { 41 | const result = card[rule] 42 | if (result.test) { 43 | message += ':white_check_mark: ' 44 | } else { 45 | if (['P01', 'P04', 'P05', 'D02'].includes(rule)) { 46 | message += ':x: ' 47 | } else { 48 | message += ':warning: ' 49 | } 50 | } 51 | } 52 | } 53 | events.add({ 54 | action: 'scorecard_added', 55 | module: packagename, 56 | version, 57 | message 58 | }) 59 | return null 60 | }) 61 | .catch((error) => { 62 | console.log(error.message) 63 | events.add({ 64 | action: 'scorecard_failed', 65 | module: packagename, 66 | version, 67 | message: error.message 68 | }) 69 | // fs.removeSync(nodePath+'/../..'); 70 | }) 71 | } 72 | 73 | module.exports = { 74 | scorecard 75 | } 76 | -------------------------------------------------------------------------------- /lib/templates.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | const mustache = require('mustache') 5 | 6 | const renderTemplates = {} 7 | const partialTemplates = {} 8 | const templateDir = path.join(__dirname, '..', 'template') 9 | 10 | fs.readdir(templateDir, function (_, files) { 11 | files.forEach(function (fn) { 12 | if (/.html$/.test(fn)) { 13 | const partname = fn.substring(0, fn.length - 5) 14 | fs.readFile(path.join(templateDir, fn), 'utf8', function (_, data) { 15 | if (fn[0] === '_') { 16 | partialTemplates[partname] = data 17 | } else { 18 | mustache.parse(data) 19 | renderTemplates[partname] = data 20 | } 21 | }) 22 | } 23 | }) 24 | }) 25 | 26 | renderTemplates.partials = partialTemplates 27 | module.exports = renderTemplates 28 | -------------------------------------------------------------------------------- /lib/users.js: -------------------------------------------------------------------------------- 1 | const db = require('./db') 2 | const github = require('./github') 3 | 4 | function extractGitHubInfo (data) { 5 | return { 6 | _id: data.login, 7 | login: data.login, 8 | avatar_url: data.avatar_url, 9 | name: data.name, 10 | bio: data.bio, 11 | html_url: data.html_url, 12 | etag: data.etag 13 | } 14 | } 15 | 16 | async function ensureExists (login, userData) { 17 | const user = await db.users.findOne({ _id: login }) 18 | if (user) { 19 | return 20 | } 21 | if (!userData) { 22 | userData = await github.getUser(login) 23 | } 24 | const userRecord = extractGitHubInfo(userData) 25 | userRecord.npm_verified = false 26 | await db.users.insertOne(userRecord) 27 | } 28 | 29 | async function refreshUserGitHub (login) { 30 | const user = await db.users.findOne({ _id: login }) 31 | if (user) { 32 | const data = await github.getUser(login) 33 | const userRecord = extractGitHubInfo(data) 34 | return update(userRecord) 35 | } 36 | throw new Error('User not found') 37 | } 38 | 39 | async function get (login) { 40 | return db.users.findOne({ _id: login }) 41 | } 42 | function update (user) { 43 | return db.users.updateOne( 44 | { _id: user._id }, 45 | { $set: user } 46 | ) 47 | } 48 | 49 | async function checkAllExist (userList) { 50 | const docs = await db.users.find({ _id: { $in: userList } }, { projection: { _id: 1 } }).toArray() 51 | const matched = {} 52 | userList.forEach(u => { matched[u] = true }) 53 | docs.forEach(d => { 54 | delete matched[d._id] 55 | }) 56 | return Object.keys(matched) 57 | } 58 | module.exports = { 59 | get, 60 | ensureExists, 61 | update, 62 | refreshUserGitHub, 63 | checkAllExist 64 | } 65 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | const csrf = require('csurf') 2 | const createDOMPurify = require('dompurify') 3 | const { JSDOM } = require('jsdom') 4 | const window = new JSDOM('').window 5 | const DOMPurify = createDOMPurify(window) 6 | const { marked } = require('marked') 7 | 8 | function formatDate (dateString) { 9 | if (!dateString) { 10 | return '' 11 | } 12 | const now = Date.now() 13 | const d = new Date(dateString) 14 | let delta = now - d.getTime() 15 | 16 | delta /= 1000 17 | 18 | if (delta < 60) { 19 | return 'seconds ago' 20 | } 21 | 22 | delta = Math.floor(delta / 60) 23 | 24 | if (delta < 10) { 25 | return 'minutes ago' 26 | } 27 | if (delta < 60) { 28 | return delta + ' minutes ago' 29 | } 30 | 31 | delta = Math.floor(delta / 60) 32 | 33 | if (delta < 24) { 34 | return delta + ' hour' + (delta > 1 ? 's' : '') + ' ago' 35 | } 36 | 37 | delta = Math.floor(delta / 24) 38 | 39 | if (delta < 7) { 40 | return delta + ' day' + (delta > 1 ? 's' : '') + ' ago' 41 | } 42 | let weeks = Math.floor(delta / 7) 43 | const days = delta % 7 44 | 45 | if (weeks < 4) { 46 | if (days === 0) { 47 | return weeks + ' week' + (weeks > 1 ? 's' : '') + ' ago' 48 | } else { 49 | return weeks + ' week' + (weeks > 1 ? 's' : '') + ', ' + days + ' day' + (days > 1 ? 's' : '') + ' ago' 50 | } 51 | } 52 | 53 | let months = Math.floor(weeks / 4) 54 | weeks = weeks % 4 55 | 56 | if (months < 12) { 57 | if (weeks === 0) { 58 | return months + ' month' + (months > 1 ? 's' : '') + ' ago' 59 | } else { 60 | return months + ' month' + (months > 1 ? 's' : '') + ', ' + weeks + ' week' + (weeks > 1 ? 's' : '') + ' ago' 61 | } 62 | } 63 | 64 | const years = Math.floor(months / 12) 65 | months = months % 12 66 | 67 | if (months === 0) { 68 | return years + ' year' + (years > 1 ? 's' : '') + ' ago' 69 | } else { 70 | return years + ' year' + (years > 1 ? 's' : '') + ', ' + months + ' month' + (months > 1 ? 's' : '') + ' ago' 71 | } 72 | } 73 | 74 | function formatShortDate (d) { 75 | let delta = Date.now() - (new Date(d)).getTime() 76 | delta /= 1000 77 | const days = Math.floor(delta / (60 * 60 * 24)) 78 | const weeks = Math.floor(days / 7) 79 | let months = Math.floor(weeks / 4) 80 | const years = Math.floor(months / 12) 81 | if (days < 7) { 82 | return days + 'd' 83 | } else if (weeks < 4) { 84 | return weeks + 'w' 85 | } else if (months < 12) { 86 | return months + 'm' 87 | } else { 88 | months = months % 12 89 | if (months > 0) { 90 | return years + 'y ' + months + 'm' 91 | } 92 | return years + 'y' 93 | } 94 | } 95 | 96 | const csrfProtection = csrf({ cookie: true }) 97 | 98 | async function renderMarkdown (src, opt) { 99 | const content = await marked.parse(src, { async: true, ...opt }) 100 | return DOMPurify.sanitize(content) 101 | } 102 | 103 | /** 104 | * Middleware that validates the user has a given role 105 | * @param {String} role one of user/mod/admin 106 | */ 107 | function requireRole (role) { 108 | return (req, res, next) => { 109 | if (req.session.user) { 110 | if (!role || role === 'user') { 111 | // Logged in user 112 | next() 113 | return 114 | } 115 | if (role === 'admin' && req.session.user.isAdmin) { 116 | next() 117 | return 118 | } 119 | if (role === 'mod' && (req.session.user.isAdmin || req.session.user.isModerator)) { 120 | next() 121 | return 122 | } 123 | } 124 | console.log('rejecting request', role, req.session.user) 125 | res.status(404).send() 126 | } 127 | } 128 | 129 | function generateSummary (desc) { 130 | let summary = (desc || '').split('\n')[0] 131 | const re = /\[(.*?)\]\(.*?\)/g 132 | let m 133 | while ((m = re.exec(summary)) !== null) { 134 | summary = summary.substring(0, m.index) + m[1] + summary.substring(m.index + m[0].length) 135 | } 136 | 137 | if (summary.length > 150) { 138 | summary = summary.substring(0, 150).split('\n')[0] + '...' 139 | } 140 | return summary 141 | } 142 | 143 | module.exports = { 144 | generateSummary, 145 | renderMarkdown, 146 | formatDate, 147 | formatShortDate, 148 | csrfProtection: () => csrfProtection, 149 | requireRole 150 | } 151 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-red-flows", 3 | "version": "0.2.0", 4 | "scripts": { 5 | "test": "mocha test/**/*_spec.js --exit", 6 | "start": "node app.js", 7 | "nodemon": "nodemon --ext js,html --inspect=0.0.0.0:5858 --ignore data/ ./app.js", 8 | "dev": "npm run nodemon", 9 | "docker": "docker-compose -f docker-compose.yml up --build" 10 | }, 11 | "dependencies": { 12 | "acorn": "8.12.1", 13 | "acorn-walk": "8.3.4", 14 | "aws-sdk": "2.1691.0", 15 | "body-parser": "1.20.3", 16 | "dompurify": "3.1.7", 17 | "connect-mongo": "5.1.0", 18 | "cookie-parser": "1.4.7", 19 | "csurf": "1.11.0", 20 | "express": "4.21.1", 21 | "express-rate-limit": "7.4.1", 22 | "express-session": "1.18.1", 23 | "fs-extra": "11.2.0", 24 | "jsdom": "25.0.1", 25 | "marked": "14.1.2", 26 | "mastodon": "1.2.2", 27 | "mongodb": "6.9.0", 28 | "mustache": "4.2.0", 29 | "node-red-dev": "^0.1.6", 30 | "oauth": "0.10.0", 31 | "request": "2.88.2", 32 | "serve-static": "1.16.2", 33 | "tar": "7.4.3", 34 | "uuid": "10.0.0", 35 | "validate-npm-package-name": "5.0.0" 36 | }, 37 | "devDependencies": { 38 | "eslint": "^8.48.0", 39 | "eslint-config-standard": "^17.1.0", 40 | "eslint-plugin-import": "^2.28.1", 41 | "eslint-plugin-n": "^16.0.2", 42 | "eslint-plugin-no-only-tests": "^3.1.0", 43 | "eslint-plugin-promise": "^6.1.1", 44 | "mocha": "8.2.1", 45 | "nodemon": "2.0.20", 46 | "should": "13.2.3", 47 | "sinon": "9.2.1", 48 | "vanilla-cookieconsent": "^2.8.8" 49 | }, 50 | "engines": { 51 | "node": "18" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/favicon.ico -------------------------------------------------------------------------------- /public/font-awesome/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/font-awesome/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /public/font-awesome/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/font-awesome/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /public/font-awesome/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/font-awesome/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /public/font-awesome/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/font-awesome/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /public/font-awesome/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/font-awesome/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /public/font/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "css_prefix_text": "icon-", 4 | "css_use_suffix": false, 5 | "hinting": true, 6 | "units_per_em": 1000, 7 | "ascent": 850, 8 | "glyphs": [ 9 | { 10 | "uid": "2d6150442079cbda7df64522dc24f482", 11 | "css": "caret-down", 12 | "code": 59392, 13 | "src": "fontawesome" 14 | }, 15 | { 16 | "uid": "9dc654095085167524602c9acc0c5570", 17 | "css": "caret-left", 18 | "code": 59393, 19 | "src": "fontawesome" 20 | }, 21 | { 22 | "uid": "fb1c799ffe5bf8fb7f8bcb647c8fe9e6", 23 | "css": "caret-right", 24 | "code": 59394, 25 | "src": "fontawesome" 26 | }, 27 | { 28 | "uid": "f5999a012fc3752386635ec02a858447", 29 | "css": "cloud-download", 30 | "code": 61677, 31 | "src": "fontawesome" 32 | }, 33 | { 34 | "uid": "861ab06e455e2de3232ebef67d60d708", 35 | "css": "minus", 36 | "code": 59395, 37 | "src": "fontawesome" 38 | }, 39 | { 40 | "uid": "44e04715aecbca7f266a17d5a7863c68", 41 | "css": "plus", 42 | "code": 59396, 43 | "src": "fontawesome" 44 | }, 45 | { 46 | "uid": "559647a6f430b3aeadbecd67194451dd", 47 | "css": "menu", 48 | "code": 61641, 49 | "src": "fontawesome" 50 | }, 51 | { 52 | "uid": "8b80d36d4ef43889db10bc1f0dc9a862", 53 | "css": "user", 54 | "code": 59397, 55 | "src": "fontawesome" 56 | }, 57 | { 58 | "uid": "5e0a374728ffa8d0ae1f331a8f648231", 59 | "css": "github", 60 | "code": 61715, 61 | "src": "fontawesome" 62 | }, 63 | { 64 | "uid": "6605ee6441bf499ffa3c63d3c7409471", 65 | "css": "move", 66 | "code": 61511, 67 | "src": "fontawesome" 68 | }, 69 | { 70 | "uid": "474656633f79ea2f1dad59ff63f6bf07", 71 | "css": "star", 72 | "code": 59398, 73 | "src": "fontawesome" 74 | }, 75 | { 76 | "uid": "d17030afaecc1e1c22349b99f3c4992a", 77 | "css": "star-empty", 78 | "code": 59399, 79 | "src": "fontawesome" 80 | }, 81 | { 82 | "uid": "84cf1fcc3fec556e7eaeb19679ca2dc9", 83 | "css": "star-half", 84 | "code": 61731, 85 | "src": "fontawesome" 86 | }, 87 | { 88 | "uid": "bbfb51903f40597f0b70fd75bc7b5cac", 89 | "css": "trash", 90 | "code": 61944, 91 | "src": "fontawesome" 92 | } 93 | ] 94 | } -------------------------------------------------------------------------------- /public/font/fontello.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/font/fontello.eot -------------------------------------------------------------------------------- /public/font/fontello.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Copyright (C) 2019 by original authors @ fontello.com 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /public/font/fontello.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/font/fontello.ttf -------------------------------------------------------------------------------- /public/font/fontello.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/font/fontello.woff -------------------------------------------------------------------------------- /public/font/fontello.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/font/fontello.woff2 -------------------------------------------------------------------------------- /public/icons/alert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/alert.png -------------------------------------------------------------------------------- /public/icons/arduino.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/arduino.png -------------------------------------------------------------------------------- /public/icons/arrow-in.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/arrow-in.png -------------------------------------------------------------------------------- /public/icons/bluetooth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/bluetooth.png -------------------------------------------------------------------------------- /public/icons/bridge-dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/bridge-dash.png -------------------------------------------------------------------------------- /public/icons/bridge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/bridge.png -------------------------------------------------------------------------------- /public/icons/comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/comment.png -------------------------------------------------------------------------------- /public/icons/db.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/db.png -------------------------------------------------------------------------------- /public/icons/debug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/debug.png -------------------------------------------------------------------------------- /public/icons/envelope.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/envelope.png -------------------------------------------------------------------------------- /public/icons/feed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/feed.png -------------------------------------------------------------------------------- /public/icons/file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/file.png -------------------------------------------------------------------------------- /public/icons/function.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/function.png -------------------------------------------------------------------------------- /public/icons/hash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/hash.png -------------------------------------------------------------------------------- /public/icons/inject.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/inject.png -------------------------------------------------------------------------------- /public/icons/leveldb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/leveldb.png -------------------------------------------------------------------------------- /public/icons/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/light.png -------------------------------------------------------------------------------- /public/icons/mongodb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/mongodb.png -------------------------------------------------------------------------------- /public/icons/mouse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/mouse.png -------------------------------------------------------------------------------- /public/icons/node-changed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/node-changed.png -------------------------------------------------------------------------------- /public/icons/node-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/node-error.png -------------------------------------------------------------------------------- /public/icons/range.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/range.png -------------------------------------------------------------------------------- /public/icons/redis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/redis.png -------------------------------------------------------------------------------- /public/icons/rpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/rpi.png -------------------------------------------------------------------------------- /public/icons/serial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/serial.png -------------------------------------------------------------------------------- /public/icons/subflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/subflow.png -------------------------------------------------------------------------------- /public/icons/swap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/swap.png -------------------------------------------------------------------------------- /public/icons/switch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/switch.png -------------------------------------------------------------------------------- /public/icons/template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/template.png -------------------------------------------------------------------------------- /public/icons/timer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/timer.png -------------------------------------------------------------------------------- /public/icons/trigger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/trigger.png -------------------------------------------------------------------------------- /public/icons/twitter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/twitter.png -------------------------------------------------------------------------------- /public/icons/watch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/watch.png -------------------------------------------------------------------------------- /public/icons/white-globe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/icons/white-globe.png -------------------------------------------------------------------------------- /public/images/add-to-collection.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/images/add-to-collection.gif -------------------------------------------------------------------------------- /public/images/flow01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/images/flow01.png -------------------------------------------------------------------------------- /public/images/loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/images/loader.gif -------------------------------------------------------------------------------- /public/images/spin.svg: -------------------------------------------------------------------------------- 1 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /public/images/user-backdrop.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/jquery/css/smoothness/images/animated-overlay.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/jquery/css/smoothness/images/animated-overlay.gif -------------------------------------------------------------------------------- /public/jquery/css/smoothness/images/ui-bg_flat_0_aaaaaa_40x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/jquery/css/smoothness/images/ui-bg_flat_0_aaaaaa_40x100.png -------------------------------------------------------------------------------- /public/jquery/css/smoothness/images/ui-bg_flat_75_ffffff_40x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/jquery/css/smoothness/images/ui-bg_flat_75_ffffff_40x100.png -------------------------------------------------------------------------------- /public/jquery/css/smoothness/images/ui-bg_glass_55_fbf9ee_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/jquery/css/smoothness/images/ui-bg_glass_55_fbf9ee_1x400.png -------------------------------------------------------------------------------- /public/jquery/css/smoothness/images/ui-bg_glass_65_ffffff_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/jquery/css/smoothness/images/ui-bg_glass_65_ffffff_1x400.png -------------------------------------------------------------------------------- /public/jquery/css/smoothness/images/ui-bg_glass_75_dadada_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/jquery/css/smoothness/images/ui-bg_glass_75_dadada_1x400.png -------------------------------------------------------------------------------- /public/jquery/css/smoothness/images/ui-bg_glass_75_e6e6e6_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/jquery/css/smoothness/images/ui-bg_glass_75_e6e6e6_1x400.png -------------------------------------------------------------------------------- /public/jquery/css/smoothness/images/ui-bg_glass_95_fef1ec_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/jquery/css/smoothness/images/ui-bg_glass_95_fef1ec_1x400.png -------------------------------------------------------------------------------- /public/jquery/css/smoothness/images/ui-bg_highlight-soft_75_cccccc_1x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/jquery/css/smoothness/images/ui-bg_highlight-soft_75_cccccc_1x100.png -------------------------------------------------------------------------------- /public/jquery/css/smoothness/images/ui-icons_222222_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/jquery/css/smoothness/images/ui-icons_222222_256x240.png -------------------------------------------------------------------------------- /public/jquery/css/smoothness/images/ui-icons_2e83ff_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/jquery/css/smoothness/images/ui-icons_2e83ff_256x240.png -------------------------------------------------------------------------------- /public/jquery/css/smoothness/images/ui-icons_454545_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/jquery/css/smoothness/images/ui-icons_454545_256x240.png -------------------------------------------------------------------------------- /public/jquery/css/smoothness/images/ui-icons_888888_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/jquery/css/smoothness/images/ui-icons_888888_256x240.png -------------------------------------------------------------------------------- /public/jquery/css/smoothness/images/ui-icons_cd0a0a_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/jquery/css/smoothness/images/ui-icons_cd0a0a_256x240.png -------------------------------------------------------------------------------- /public/js/tags.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-unused-vars 2 | const tagger = function (options) { 3 | let tags = [] 4 | options = options || {} 5 | const lipre = options.lipre || '' 6 | const lipost = options.lipost || '' 7 | 8 | const tagList = $('ul#add-flow-tags') 9 | const originalTags = [] 10 | 11 | function formatTag (tag) { 12 | return lipre.replace(/@@TAG@@/g, tag) + tag + lipost.replace(/@@TAG@@/g, tag) 13 | } 14 | 15 | $('li', tagList).each(function (i, e) { 16 | const li = $(e) 17 | const tag = li.attr('tag') 18 | li.html(tag + ' ') 19 | $('a', li).click(function (e) { 20 | removeTag(tag) 21 | e.preventDefault() 22 | }) 23 | tags.push(tag) 24 | originalTags.push(tag) 25 | }) 26 | 27 | const listInput = $('
  • ') 28 | tagList.append(listInput) 29 | 30 | const tagInput = $('#add-flow-tags-input') 31 | tagList.click(function (e) { 32 | tagInput.focus() 33 | }) 34 | tagInput.on('focusin', function (e) { 35 | tagList.addClass('active') 36 | }) 37 | tagInput.on('focusout', function (e) { 38 | tagList.removeClass('active') 39 | const val = tagInput.val() 40 | if (val !== '') { 41 | addTag(val) 42 | tagInput.val('') 43 | } 44 | }) 45 | tagInput.on('keydown', function (e) { 46 | if (e.which === 32 || (e.which === 188 && !e.shiftKey)) { 47 | const val = tagInput.val() 48 | if (val !== '') { 49 | if (addTag(val)) { 50 | tagInput.val('') 51 | } 52 | } 53 | e.preventDefault() 54 | } else if (e.which === 8) { 55 | const val = tagInput.val() 56 | if (val === '') { 57 | const prevTag = $(this).parent().prev().attr('tag') 58 | if (prevTag) { 59 | removeTag(prevTag) 60 | } 61 | e.preventDefault() 62 | } 63 | } 64 | }) 65 | 66 | function strip () { 67 | $('li', tagList).each(function (i, e) { 68 | const li = $(e) 69 | if (li.hasClass('tag-input')) { 70 | li.remove() 71 | } else { 72 | const tag = $(li).attr('tag') 73 | li.html(formatTag(tag)) 74 | } 75 | }) 76 | } 77 | 78 | function cancel () { 79 | $('li', tagList).remove() 80 | tags = originalTags 81 | for (const i in tags) { 82 | tagList.append($('
  • ').html(formatTag(tags[i])).attr('tag', tags[i])) 83 | } 84 | } 85 | function addTag (tag) { 86 | tag = tag.replace(/&/g, '&').replace(//g, '>') 87 | const i = $.inArray(tag, tags) 88 | if (i === -1) { 89 | tags.push(tag) 90 | 91 | const newtag = $('
  • ').html(tag + ' ') 92 | $(newtag).attr('tag', tag) 93 | $('a', newtag).click(function (e) { 94 | removeTag(tag) 95 | e.preventDefault() 96 | }) 97 | tagInput.parent().before(newtag) 98 | return true 99 | } else { 100 | const existingTag = $("li[tag='" + tag + "']", tagList) 101 | existingTag.css({ borderColor: '#f00', background: '#fcc' }) 102 | window.setTimeout(function () { 103 | existingTag.css({ borderColor: '#ccc', background: '#f5f5f5' }) 104 | }, 1000) 105 | return false 106 | } 107 | } 108 | function removeTag (tag) { 109 | const i = $.inArray(tag, tags) 110 | if (i !== -1) { 111 | tags.splice(i, 1) 112 | 113 | $('li', tagList).each(function (i, e) { 114 | if ($(e).attr('tag') === tag) { 115 | e.remove() 116 | } 117 | }) 118 | } 119 | } 120 | return { 121 | add: addTag, 122 | remove: removeTag, 123 | get: function () { return tags }, 124 | strip, 125 | cancel 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /public/node-red-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/node-red-white.png -------------------------------------------------------------------------------- /public/node-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-red/flow-library/659323234a0569da8b23f6a20cd79b6bc5a050ee/public/node-red.png -------------------------------------------------------------------------------- /routes/admin.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const mustache = require('mustache') 3 | 4 | const events = require('../lib/events') 5 | const templates = require('../lib/templates') 6 | 7 | const app = express() 8 | app.get('/admin/log', async function (req, res) { 9 | const context = {} 10 | context.sessionuser = req.session.user 11 | try { 12 | context.events = await events.get() 13 | res.send(mustache.render(templates.events, context, templates.partials)) 14 | } catch (err) { 15 | console.log(err) 16 | context.err = err 17 | context.events = [] 18 | res.send(mustache.render(templates.events, context, templates.partials)) 19 | } 20 | }) 21 | 22 | module.exports = app 23 | -------------------------------------------------------------------------------- /routes/api.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | 3 | const npmNodes = require('../lib/nodes') 4 | const view = require('../lib/view') 5 | 6 | const app = express() 7 | 8 | /** 9 | * get flows and nodes that match query params 10 | */ 11 | app.get('/api/v1/search', async function (req, res) { 12 | const result = await view.getForQuery(req.query) 13 | res.json(result) 14 | }) 15 | 16 | app.get('/api/types/:type', async function (req, res) { 17 | try { 18 | const typeMap = await npmNodes.findTypes([req.params.type]) 19 | res.json(typeMap[req.params.type] || (npmNodes.CORE_NODES[req.params.type] ? ['@node-red/nodes'] : [])) 20 | } catch (err) { 21 | console.log(err) 22 | res.send(400) 23 | } 24 | }) 25 | app.post('/api/types', async function (req, res) { 26 | const typeList = req.body.types || [] 27 | 28 | const result = {} 29 | 30 | if (Array.isArray(typeList)) { 31 | const typeMap = await npmNodes.findTypes(typeList) 32 | typeList.forEach(function (t) { 33 | if (typeMap[t]) { 34 | result[t] = typeMap[t] 35 | } else if (npmNodes.CORE_NODES[t]) { 36 | result[t] = ['@node-red/nodes'] 37 | } else { 38 | result[t] = [] 39 | } 40 | }) 41 | res.json(result) 42 | } else { 43 | res.end(400) 44 | } 45 | }) 46 | module.exports = app 47 | -------------------------------------------------------------------------------- /routes/auth.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const OAuth2 = require('oauth').OAuth2 3 | 4 | const settings = require('../config') 5 | const github = require('../lib/github') 6 | const users = require('../lib/users') 7 | 8 | const oauth = new OAuth2(settings.github.clientId, settings.github.secret, 'https://github.com/', 'login/oauth/authorize', 'login/oauth/access_token') 9 | 10 | const app = express() 11 | 12 | function login (req, res) { 13 | if (!req.session.accessToken) { 14 | if (req.query.return) { 15 | req.session.returnPath = req.query.return 16 | } else { 17 | delete req.session.returnPath 18 | } 19 | res.writeHead(303, { 20 | Location: oauth.getAuthorizeUrl({ 21 | redirect_uri: settings.github.authCallback, 22 | scope: 'gist' 23 | }) 24 | }) 25 | res.end() 26 | } else { 27 | res.writeHead(302, { 28 | Location: req.query.return || '/' 29 | }) 30 | res.end() 31 | } 32 | } 33 | function logout (req, res) { 34 | req.session.destroy(function (_) { 35 | res.redirect('/') 36 | }) 37 | } 38 | function loginCallback (req, res) { 39 | if (!req.query.code) { 40 | res.writeHead(403) 41 | res.end() 42 | return 43 | } 44 | oauth.getOAuthAccessToken(req.query.code, {}, async function (err, accessToken, refreshToken) { 45 | if (err) { 46 | console.log(err) 47 | res.writeHead(500) 48 | res.end(err + '') 49 | return 50 | } 51 | if (!accessToken) { 52 | res.writeHead(403) 53 | res.end() 54 | return 55 | } 56 | req.session.accessToken = accessToken 57 | try { 58 | const user = await github.getAuthedUser(req.session.accessToken) 59 | await users.ensureExists(user.login, user) 60 | req.session.user = { 61 | login: user.login, 62 | avatar_url: user.avatar_url, 63 | url: user.html_url, 64 | name: user.name 65 | } 66 | res.writeHead(303, { 67 | Location: req.session.returnPath || '/' 68 | }) 69 | res.end() 70 | } catch (err) { 71 | if (err) { 72 | res.writeHead(400) 73 | res.end(err + '') 74 | } 75 | } 76 | }) 77 | } 78 | 79 | app.get('/login', login) 80 | app.get('/logout', logout) 81 | app.get('/login/callback', loginCallback) 82 | 83 | module.exports = app 84 | -------------------------------------------------------------------------------- /routes/categories.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const mustache = require('mustache') 3 | 4 | const categories = require('../lib/categories') 5 | const templates = require('../lib/templates') 6 | const appUtils = require('../lib/utils') 7 | 8 | const app = express() 9 | 10 | /** 11 | * Page: Browse Categories 12 | */ 13 | app.get('/categories', async function (req, res) { 14 | const context = {} 15 | context.categories = await categories.getAll() 16 | context.sessionuser = req.session.user 17 | context.isAdmin = req.session.user?.isAdmin 18 | context.isModerator = req.session.user?.isModerator 19 | res.send(mustache.render(templates.categories, context, templates.partials)) 20 | }) 21 | 22 | /** 23 | * Page: Add Category 24 | */ 25 | app.get('/add/category', appUtils.requireRole('admin'), function (req, res) { 26 | if (!req.session.user) { 27 | return res.redirect('/add') 28 | } 29 | const context = {} 30 | context.sessionuser = req.session.user 31 | res.send(mustache.render(templates.addCategory, context, templates.partials)) 32 | }) 33 | 34 | /** 35 | * API: Add Category 36 | */ 37 | app.post('/categories', appUtils.requireRole('admin'), async function (req, res) { 38 | const collection = { 39 | name: req.body.title, 40 | description: req.body.description 41 | } 42 | try { 43 | const id = await categories.create(collection) 44 | res.send('/categories/' + id) 45 | } catch (err) { 46 | console.log('Error creating category:', err) 47 | res.send(err) 48 | } 49 | }) 50 | 51 | /** 52 | * Page: Category view 53 | */ 54 | app.get('/categories/:category', async function (req, res) { 55 | const context = {} 56 | context.sessionuser = req.session.user 57 | context.isAdmin = req.session.user?.isAdmin 58 | context.isModerator = req.session.user?.isModerator 59 | context.query = { 60 | category: req.params.category, 61 | type: 'node', 62 | hideOptions: true, 63 | ignoreQueryParams: true 64 | } 65 | try { 66 | context.category = await categories.get(req.params.category) 67 | context.category.summary = await appUtils.renderMarkdown(context.category.summary) 68 | context.category.description = await appUtils.renderMarkdown(context.category.description) 69 | 70 | res.send(mustache.render(templates.category, context, templates.partials)) 71 | } catch (err) { 72 | if (err) { 73 | console.log('error loading nodes:', err) 74 | } 75 | res.status(404).send(mustache.render(templates['404'], context, templates.partials)) 76 | } 77 | }) 78 | 79 | /** 80 | * Page: Edit Category 81 | */ 82 | app.get('/categories/:category/edit', appUtils.csrfProtection(), appUtils.requireRole('admin'), async function (req, res) { 83 | const context = {} 84 | context.csrfToken = req.csrfToken() 85 | context.sessionuser = req.session.user 86 | try { 87 | context.category = await categories.get(req.params.category) 88 | res.send(mustache.render(templates.addCategory, context, templates.partials)) 89 | res.end() 90 | } catch (err) { 91 | console.log('err', err) 92 | res.sendStatus(400) 93 | } 94 | }) 95 | 96 | /** 97 | * API: Edit Category 98 | */ 99 | app.put('/categories/:category', appUtils.csrfProtection(), appUtils.requireRole('admin'), async function (req, res) { 100 | const category = { 101 | _id: req.params.category 102 | } 103 | if (req.body.title) { 104 | category.name = req.body.title.trim() 105 | } 106 | if (Object.prototype.hasOwnProperty.call(req.body, 'description')) { 107 | category.description = req.body.description 108 | } 109 | try { 110 | await categories.update(category) 111 | res.send('/categories/' + req.params.category) 112 | } catch (err) { 113 | console.log('Error updating category:', err) 114 | res.status(400).json(err) 115 | } 116 | }) 117 | 118 | module.exports = app 119 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | const querystring = require('querystring') 2 | 3 | const express = require('express') 4 | const mustache = require('mustache') 5 | 6 | const settings = require('../config') 7 | const categories = require('../lib/categories') 8 | const templates = require('../lib/templates') 9 | const viewster = require('../lib/view') 10 | 11 | const app = express() 12 | 13 | function queryFromRequest (req) { 14 | const query = Object.assign({}, req.query) 15 | query.page = Number(query.page) || 1 16 | query.num_pages = Number(query.num_pages) || 1 17 | query.page_size = Number(query.page_size) || viewster.DEFAULT_PER_PAGE 18 | return query 19 | } 20 | function getNextPageQueryString (count, query) { 21 | const currentPage = parseInt(query.page) || 1 22 | if (viewster.DEFAULT_PER_PAGE * currentPage < count) { 23 | return querystring.stringify(Object.assign({}, query, { page: currentPage + 1 })) 24 | } 25 | return null 26 | } 27 | function getPrevPageQueryString (count, query) { 28 | const currentPage = parseInt(query.page) || 1 29 | if (currentPage > 1) { 30 | return querystring.stringify(Object.assign({}, query, { page: currentPage - 1 })) 31 | } 32 | return null 33 | } 34 | app.use(function (req, res, next) { 35 | if (req.session.user) { 36 | req.session.user.isAdmin = settings.admins.indexOf(req.session.user.login) !== -1 37 | req.session.user.isModerator = req.session.user.isAdmin || settings.moderators.indexOf(req.session.user.login) !== -1 38 | } 39 | next() 40 | }) 41 | app.get('/', async function (req, res) { 42 | const context = {} 43 | 44 | context.categories = await categories.getAll() 45 | context.sessionuser = req.session.user 46 | context.isAdmin = req.session.user?.isAdmin 47 | context.isModerator = req.session.user?.isModerator 48 | context.nodes = { 49 | type: 'node', 50 | per_page: context.sessionuser ? 6 : 3, 51 | hideOptions: true, 52 | hideNav: true, 53 | ignoreQueryParams: true 54 | } 55 | context.flows = { 56 | type: 'flow', 57 | per_page: context.sessionuser ? 6 : 3, 58 | hideOptions: true, 59 | hideNav: true, 60 | ignoreQueryParams: true 61 | } 62 | context.collections = { 63 | type: 'collection', 64 | per_page: context.sessionuser ? 6 : 3, 65 | hideOptions: true, 66 | hideNav: true, 67 | ignoreQueryParams: true 68 | } 69 | const counts = await viewster.getTypeCounts() 70 | context.nodes.count = counts.node 71 | context.flows.count = counts.flow 72 | context.collections.count = counts.collection 73 | 74 | res.send(mustache.render(templates.index, context, templates.partials)) 75 | }) 76 | 77 | app.get('/things', async function (req, res) { 78 | const response = { 79 | links: { 80 | self: '/things?' + querystring.stringify(req.query), 81 | prev: null, 82 | next: null 83 | }, 84 | meta: { 85 | pages: { 86 | current: parseInt(req.query.page) || 1 87 | }, 88 | results: { 89 | 90 | } 91 | } 92 | } 93 | const query = queryFromRequest(req) 94 | 95 | try { 96 | const result = await viewster.getForQuery(query) 97 | result.things = result.things || [] 98 | result.things.forEach(function (thing) { 99 | thing.isNode = thing.type === 'node' 100 | thing.isFlow = thing.type === 'flow' 101 | thing.isCollection = thing.type === 'collection' 102 | }) 103 | response.meta.results.count = result.count 104 | response.meta.results.total = result.total 105 | response.meta.pages.total = Math.ceil(result.count / viewster.DEFAULT_PER_PAGE) 106 | const nextQS = getNextPageQueryString(result.count, req.query) 107 | const prevQS = getPrevPageQueryString(result.count, req.query) 108 | 109 | if (nextQS) { 110 | response.links.next = '/things?' + nextQS 111 | } 112 | if (prevQS) { 113 | response.links.prev = '/things?' + prevQS 114 | } 115 | const context = { 116 | things: result.things, 117 | toFixed: function () { 118 | return function (num, render) { 119 | return parseFloat(render(num)).toFixed(1) 120 | } 121 | } 122 | } 123 | if (req.session.user) { 124 | context.showTools = {} 125 | if (result.collectionOwners) { 126 | for (let i = 0; i < result.collectionOwners.length; i++) { 127 | if (result.collectionOwners[i] === req.session.user.login) { 128 | context.showTools.ownedCollection = true 129 | break 130 | } 131 | } 132 | } 133 | } 134 | if (query.collection) { 135 | context.collection = query.collection 136 | } 137 | if (query.format !== 'json') { 138 | response.html = mustache.render(templates.partials._gistitems, context, templates.partials) 139 | } else { 140 | response.data = result.things 141 | } 142 | setTimeout(function () { 143 | res.json(response) 144 | }, 0)// 2000); 145 | } catch (err) { 146 | response.err = err 147 | res.json(response) 148 | } 149 | }) 150 | 151 | app.get('/search', async function (req, res) { 152 | const context = {} 153 | context.sessionuser = req.session.user 154 | context.isAdmin = req.session.user?.isAdmin 155 | context.isModerator = req.session.user?.isModerator 156 | context.fullsearch = true 157 | context.categories = await categories.getAll() 158 | const query = queryFromRequest(req) 159 | context.query = query 160 | res.send(mustache.render(templates.search, context, templates.partials)) 161 | }) 162 | 163 | app.get('/add', function (req, res) { 164 | const context = {} 165 | context.sessionuser = req.session.user 166 | res.send(mustache.render(templates.add, context, templates.partials)) 167 | }) 168 | 169 | app.get('/inspect', function (req, res) { 170 | const context = {} 171 | res.send(mustache.render(templates.flowInspector, context, templates.partials)) 172 | }) 173 | module.exports = app 174 | -------------------------------------------------------------------------------- /routes/users.js: -------------------------------------------------------------------------------- 1 | const https = require('https') 2 | 3 | const express = require('express') 4 | const mustache = require('mustache') 5 | 6 | const db = require('../lib/db') 7 | const templates = require('../lib/templates') 8 | const users = require('../lib/users') 9 | const appUtils = require('../lib/utils') 10 | 11 | const app = express() 12 | 13 | app.get('/user/:username', async function (req, res) { 14 | const context = {} 15 | context.sessionuser = req.session.user 16 | context.username = req.params.username 17 | context.query = { 18 | id: Math.floor(Math.random() * 16777215).toString(16), 19 | username: req.params.username, 20 | sort: 'recent', 21 | type: '' 22 | } 23 | 24 | const user = await db.users.find({ _id: context.query.username }).toArray() 25 | if (user && user.length > 0) { 26 | context.user = user[0] 27 | if (user[0].npm_login && user[0].npm_login !== context.username) { 28 | context.query.npm_username = user[0].npm_login 29 | } 30 | } 31 | res.send(mustache.render(templates.user, context, templates.partials)) 32 | }) 33 | 34 | app.get('/settings', appUtils.csrfProtection(), async function (req, res) { 35 | if (!req.session.accessToken) { 36 | res.writeHead(302, { 37 | Location: '/' 38 | }) 39 | res.end() 40 | return 41 | } 42 | const context = {} 43 | context.sessionuser = req.session.user 44 | context.csrfToken = req.csrfToken() 45 | const username = req.session.user.login 46 | try { 47 | context.user = await users.get(username) 48 | res.send(mustache.render(templates.userSettings, context, templates.partials)) 49 | } catch (err) { 50 | context.err = err 51 | res.send(mustache.render(templates.userSettings, context, templates.partials)) 52 | } 53 | }) 54 | 55 | app.post('/settings/github-refresh', appUtils.csrfProtection(), async function (req, res) { 56 | if (!req.session.accessToken) { 57 | res.status(401).end() 58 | return 59 | } 60 | const username = req.session.user.login 61 | try { 62 | await users.refreshUserGitHub(username) 63 | res.writeHead(303, { 64 | Location: '/settings' 65 | }) 66 | res.end() 67 | } catch (err) { 68 | console.log('Refresh github failed. ERR:', err) 69 | res.writeHead(303, { 70 | Location: '/settings' 71 | }) 72 | res.end() 73 | } 74 | }) 75 | app.post('/settings/npm-remove', appUtils.csrfProtection(), async function (req, res) { 76 | if (!req.session.accessToken) { 77 | res.status(401).end() 78 | return 79 | } 80 | const username = req.session.user.login 81 | try { 82 | const user = await users.get(username) 83 | user.npm_verified = false 84 | user.npm_login = '' 85 | await users.update(user) 86 | res.writeHead(303, { 87 | Location: '/settings' 88 | }) 89 | res.end() 90 | } catch (err) { 91 | console.log('Error updating user: ' + err) 92 | res.status(400).end() 93 | } 94 | }) 95 | 96 | app.post('/settings/npm-verify', appUtils.csrfProtection(), function (req, res) { 97 | if (!req.session.accessToken) { 98 | res.status(401).end() 99 | return 100 | } 101 | const username = req.session.user.login 102 | const token = req.body.token || '' 103 | const options = { 104 | host: 'registry.npmjs.org', 105 | port: 443, 106 | path: '/-/npm/v1/user', 107 | method: 'get', 108 | headers: { 109 | Authorization: 'Bearer ' + token 110 | } 111 | } 112 | const request = https.request(options, function (response) { 113 | response.setEncoding('utf8') 114 | let data = '' 115 | response.on('data', function (chunk) { 116 | data += chunk 117 | }) 118 | response.on('end', function () { 119 | if (/^application\/json/.test(response.headers['content-type'])) { 120 | data = JSON.parse(data) 121 | } 122 | if (response.statusCode !== 200) { 123 | res.writeHead(303, { 124 | Location: '/settings#npm-verify=fail' 125 | }) 126 | res.end() 127 | return 128 | } 129 | users.get(username).then(function (user) { 130 | user.npm_verified = true 131 | user.npm_login = data.name 132 | return users.update(user) 133 | }).then(user => { 134 | res.writeHead(303, { 135 | Location: '/settings#npm-verify=success' 136 | }) 137 | res.end() 138 | return null 139 | }).catch(err => { 140 | console.log('Error updating user: ' + err) 141 | res.status(400).end() 142 | }) 143 | }) 144 | }) 145 | request.on('error', function (e) { 146 | console.log('problem with request: ' + e.message) 147 | res.status(400).end() 148 | }) 149 | request.end() 150 | }) 151 | 152 | module.exports = app 153 | -------------------------------------------------------------------------------- /tasks/add-gist.js: -------------------------------------------------------------------------------- 1 | const db = require('../lib/db') 2 | const gists = require('../lib/gists') 3 | const id = process.argv[2] 4 | 5 | if (!id) { 6 | console.log('Usage: node add-gist.js ') 7 | process.exitCode = 1 8 | return 9 | } 10 | 11 | ;(async function () { 12 | try { 13 | await gists.add(id) 14 | console.log('Success') 15 | } catch (err) { 16 | console.log(err) 17 | } finally { 18 | db.close() 19 | } 20 | })() 21 | -------------------------------------------------------------------------------- /tasks/generate_catalog.js: -------------------------------------------------------------------------------- 1 | const db = require('../lib/db') 2 | const viewster = require('../lib/view') 3 | 4 | ;(async function () { 5 | await db.init() 6 | try { 7 | const things = await viewster.get({ type: 'node' }, null, { 8 | _id: 1, 9 | updated_at: 1, 10 | 'dist-tags.latest': 1, 11 | official: 1, 12 | description: 1, 13 | keywords: 1, 14 | types: 1, 15 | categories: 1, 16 | downloads: 1, 17 | deprecated: 1 18 | }) 19 | const modules = things.map(function (t) { 20 | return { 21 | id: t._id, 22 | version: t['dist-tags'].latest, 23 | description: t.description, 24 | updated_at: t.updated_at, 25 | types: t.types, 26 | keywords: t.keywords, 27 | categories: t.categories, 28 | url: 'https://flows.nodered.org/node/' + t._id, 29 | downloads: t.downloads, 30 | deprecated: t.deprecated || undefined 31 | } 32 | }) 33 | 34 | console.log('{') 35 | console.log(' "name": "Node-RED Community catalogue",') 36 | console.log(' "updated_at": "' + (new Date()).toISOString() + '",') 37 | console.log(' "modules":') 38 | console.log(JSON.stringify(modules)) 39 | console.log('}') 40 | } catch (err) { 41 | console.log(err) 42 | process.exitCode = 1 43 | } finally { 44 | await db.close() 45 | } 46 | })() 47 | -------------------------------------------------------------------------------- /tasks/refresh-gist.js: -------------------------------------------------------------------------------- 1 | const db = require('../lib/db') 2 | const gists = require('../lib/gists') 3 | 4 | const id = process.argv[2] 5 | 6 | if (!id) { 7 | console.log('Usage: node refresh-gist.js ') 8 | process.exitCode = 1 9 | return 10 | } 11 | 12 | ;(async function () { 13 | await db.init() 14 | try { 15 | const result = await gists.refresh(id) 16 | if (result === null) { 17 | console.log('No update needed') 18 | } else { 19 | console.log('Updated') 20 | } 21 | } catch (err) { 22 | console.log(err) 23 | } 24 | await db.close() 25 | })() 26 | -------------------------------------------------------------------------------- /tasks/remove-gist.js: -------------------------------------------------------------------------------- 1 | const db = require('../lib/db') 2 | const gists = require('../lib/gists') 3 | 4 | const id = process.argv[2] 5 | 6 | if (!id) { 7 | console.log('Usage: node remove-gist.js ') 8 | process.exitCode = 1 9 | return 10 | } 11 | 12 | ;(async function () { 13 | await db.init() 14 | try { 15 | await gists.remove(id) 16 | } catch (err) { 17 | console.log('Failed', err) 18 | } 19 | await db.close() 20 | })() 21 | -------------------------------------------------------------------------------- /tasks/update-download-stats.js: -------------------------------------------------------------------------------- 1 | const db = require('../lib/db') 2 | const npmModules = require('../lib/modules') 3 | const npmNodes = require('../lib/nodes') 4 | 5 | ;(async function () { 6 | await db.init() 7 | try { 8 | await npmModules.refreshDownloads() 9 | } catch (err) { 10 | console.log(err) 11 | } finally { 12 | await npmNodes.close() 13 | } 14 | })() 15 | -------------------------------------------------------------------------------- /tasks/update-one.js: -------------------------------------------------------------------------------- 1 | const db = require('../lib/db') 2 | const npmModules = require('../lib/modules') 3 | const npmNodes = require('../lib/nodes') 4 | const name = process.argv[2] 5 | 6 | if (!name) { 7 | console.log('Usage: node update-one.js ') 8 | process.exitCode = 1 9 | return 10 | } 11 | ;(async function () { 12 | await db.init() 13 | const results = await npmModules.refreshModule(name) 14 | results.forEach(function (res) { 15 | if (res.state === 'rejected') { 16 | console.log('Failed:', res.reason) 17 | } else if (res.value) { 18 | console.log(res.value) 19 | } else { 20 | console.log('Nothing to do', res) 21 | } 22 | }) 23 | npmNodes.close() 24 | })() 25 | -------------------------------------------------------------------------------- /template/404.html: -------------------------------------------------------------------------------- 1 | {{>_header}} 2 |
    3 |

    404 - Thing not found

    4 |
    5 | {{>_footer}} 6 | -------------------------------------------------------------------------------- /template/_D01.html: -------------------------------------------------------------------------------- 1 |

    Number of Dependencies

    2 |
    Requirements
    3 |

    The tool will check the number of dependencies in the package.json and warn if there are more than six. 4 | devDependencies are not counted.

    5 |
    Reason
    6 |

    95% of all published Node-RED modules had less than six dependencies when tested (Oct 2021). 7 | Occasionally packages include a large number of unused dependencies, including the node-red package itself! 8 | This isn't meant to be a hard limit, just a warning to check that everything in your dependencies list is indeed needed. Some packages may have 9 | a legitimate reason to have a lot of dependencies and this can be noted in the README

    10 |
    Reference
    11 |

    https://docs.npmjs.com/cli/v7/configuring-npm/package-json#dependencies

    12 | 13 | -------------------------------------------------------------------------------- /template/_D02.html: -------------------------------------------------------------------------------- 1 |

    Bad Packages

    2 |
    Requirements
    3 |

    The tool will check that any packages with known incompatibilities to Node-RED core are not used in the dependency tree.

    4 |
    Reason
    5 |

    As of Oct 2021 there has only been one package that caused an issue, (agent-base <6.0.0). However this facility allows us to expand this list should the need arise in the future.

    6 |
    Reference
    7 |

    https://github.com/node-red/node-red-dev-cli/blob/main/src/badpackages.json

    8 | 9 | -------------------------------------------------------------------------------- /template/_D03.html: -------------------------------------------------------------------------------- 1 |

    Out of Date Dependencies

    2 |
    Requirements
    3 |

    The tool will check the dependencies listed in the package.json and if there is a newer version that does not satisfy the stated version it will flag a warning. 4 | For example if your dependencies are:

    5 |
    { "acme" : "~1.0.0"}
    6 |

    And the latest version of acme is 1.1.3 then this will not warn as the latest version will be installed when the package is added to node-red. 7 | However if acme is at 2.0.0 it would flag a warning.

    8 |
    Reason
    9 |

    Packages are often updated to include security fixes so users should be able to use the newest version available. 10 | When your package needs to use an older version this should be noted in the README

    11 |
    Reference
    12 |

    https://docs.npmjs.com/cli/v7/configuring-npm/package-json#dependencies

    13 | 14 | -------------------------------------------------------------------------------- /template/_N01.html: -------------------------------------------------------------------------------- 1 |

    Node Names

    2 |
    Requirements
    3 |

    Nodes SHOULD use unique names. 4 | The tool will check the names for each node in your package against other packages already published to the catalog and highlight any duplicates.

    5 |
    Reason
    6 |

    A user cannot install two different nodes that use the same name.

    7 |
    Reference
    8 |

    https://nodered.org/docs/creating-nodes/packaging#packagejson

    9 | 10 | -------------------------------------------------------------------------------- /template/_N02.html: -------------------------------------------------------------------------------- 1 |

    Examples

    2 |
    Requirements
    3 |

    The package SHOULD include example flows that demonstrate each node. 4 | The tool will check the examples folder (and any nested folders) for json flow files. It will then iterate over these files and look at the nodes used in the flows to ensure that all nodes declared in the package are used in an example at least once. Config nodes are excluded from the check.

    5 |
    Reason
    6 |

    Examples are an excellent way for users to understand how to use a node.

    7 |
    Reference
    8 |

    https://nodered.org/docs/creating-nodes/examples

    9 | 10 | -------------------------------------------------------------------------------- /template/_P01.html: -------------------------------------------------------------------------------- 1 |

    License

    2 |
    Requirement
    3 |

    The package.json MUST contain a key of license that specifies the type of license used, this can be either an open source license or your own proprietary license. The value should be either a recognised SPDX ID or refer to a filename within the package for a custom license.

    4 |
    Reason
    5 |

    The package must specify a license so that users can have confidence they are permitted to use it and understand any restrictions on its use.

    6 |
    Reference
    7 |

    https://docs.npmjs.com/cli/v7/configuring-npm/package-json#license

    8 | -------------------------------------------------------------------------------- /template/_P02.html: -------------------------------------------------------------------------------- 1 |

    Check package name in repository is the same name as the package.json

    2 |
    Requirement
    3 |

    The name field in the package.json SHOULD match the name field in the package.json hosted within the repository specified in the package.json.

    4 |
    Reason
    5 |

    This is to check for forked nodes where the publisher has forked an original repo and published the node under a new name but not updated the repo to point to their own new copy. This can result in authors of the original node getting issues raised against their repository for a downstream fork.

    6 |
    Note
    7 |

    This test may fail when you are validating a local copy of the package and it has not yet been published, or when the code has not been published in a repository.

    8 |

    Where code is part of a mono-repo you should use the directory key within repository to point to the path where the package.json is located.

    9 |
    Reference
    10 |

    https://docs.npmjs.com/cli/v7/configuring-npm/package-json#name

    11 | 12 | -------------------------------------------------------------------------------- /template/_P03.html: -------------------------------------------------------------------------------- 1 |

    Package.json must contain a Repository or Bugs URL or Email

    2 |
    Requirement
    3 |

    The package.json file MUST contain either a repository object or a bugs object. 4 | If bugs is specified then either a URL or an email address can by supplied.

    5 |
    Reason
    6 |

    This is so that users can report bugs and request support when using the package.

    7 |
    Reference
    8 |

    https://docs.npmjs.com/cli/v7/configuring-npm/package-json#bugs

    9 | 10 | -------------------------------------------------------------------------------- /template/_P04.html: -------------------------------------------------------------------------------- 1 |

    Naming

    2 |
    Requirements
    3 |

    New packages published to npm after 1st December 2021 should use a scoped name, they may then use any value in their package name eg @devname/acme-node-red

    4 |

    Packages first published before this date can use unscoped names, however when the package name starts with node-red (or nodered then it MUST use node-red-contrib

    5 |

    The following names would be allowed: 6 | node-red-contrib-acme 7 | nodered-contrib-acme 8 | acme-nodered

    9 |

    The following would not be allowed: 10 | node-red-acme 11 | nodered-acme

    12 |
    Reason
    13 |

    Initially the contrib formatting was used to distinguish 3rd party nodes from core nodes created by the project. 14 | As the number of nodes has grown the namespace has become more cluttered, a move to scoped names will allow for cleaner naming of nodes by service providers. 15 | Scoped names also allow for a developer to fork an abandoned node and publish a new version with the same pacakge name but within their scope. This makes it clearer to users where multiple packages exist.

    16 |
    Reference
    17 |

    https://nodered.org/docs/creating-nodes/packaging#naming

    18 | 19 | -------------------------------------------------------------------------------- /template/_P05.html: -------------------------------------------------------------------------------- 1 |

    Node-RED Keyword

    2 |
    Requirements
    3 |

    The package.json must contain the keyword node-red eg

    4 |
        "keywords": [ "node-red" ],
    5 |
    Reason
    6 |

    This identifies the package as a Node-RED module rather than a more generic node package.

    7 |

    Without this keyword set, it will not be listed on the Flow Library, but it will still be npm installable. 8 |
    9 | If the module is not yet ready for wider use by the Node-RED community, you should not include the keyword. 10 |

    11 |
    Reference
    12 |

    https://nodered.org/docs/creating-nodes/packaging#packagejson

    13 | 14 | -------------------------------------------------------------------------------- /template/_P06.html: -------------------------------------------------------------------------------- 1 |

    Minimum Node-RED version

    2 |
    Requirements
    3 |

    Within the node-red section of the package.json file the developer SHOULD indicate the minimum supported version. This must satisfy at least one of the current latest or maintenance tagged releases.

    4 |

    eg

    5 |
    "node-red"  : {
     6 |     "version": ">=2.0.0",
     7 | }
    8 |
    Reason
    9 |

    This is so that users can identify nodes that rely on new features in latest versions of Node-RED.

    10 |
    Reference
    11 |

    https://nodered.org/docs/creating-nodes/packaging#packagejson

    12 | 13 | -------------------------------------------------------------------------------- /template/_P07.html: -------------------------------------------------------------------------------- 1 |

    Minimum Node.js version

    2 |
    Requirements
    3 |

    Within the engines section of the package.json file you SHOULD declare the minimum version of Node that your package works on. 4 | This SHOULD satisfy the current minimum supported version of the latest Node-RED release.

    5 |
    
     6 | {
     7 |   "engines": {
     8 |     "node": ">=12.0.0"
     9 |   }
    10 | }
    11 |
    Reason
    12 |

    Node-RED has supported multiple versions of Node in its history and some of these have become end of life, this helps users identify if a node will run on their installation.

    13 |
    Reference
    14 |

    https://docs.npmjs.com/cli/v7/configuring-npm/package-json#engines

    15 | -------------------------------------------------------------------------------- /template/_P08.html: -------------------------------------------------------------------------------- 1 |

    Similar Names

    2 |
    Requirements
    3 |

    The package should use a unique name. 4 | The tool will check for packages of the same name with a different scope and these may be highlighted to the user.

    5 |
    Reason
    6 |

    Packages should use unique names. When a package is abandoned and another developer updates it and publishes a fork under their own scope, this will help users to identify that they are similar packages. The README should be clear when a package is a fork and what the changes are.

    7 |
    Reference
    8 | 9 | -------------------------------------------------------------------------------- /template/_category.html: -------------------------------------------------------------------------------- 1 | {{>_header}} 2 |
    3 |

    Tag: {{ tag }}

    4 |
    5 |
    6 |
    7 |
      {{#gists}}{{>_gistlist}}{{/gists}}
    8 |
    9 |
    10 | {{>_footer}} 11 | -------------------------------------------------------------------------------- /template/_categoryBox.html: -------------------------------------------------------------------------------- 1 |
  • 2 | 3 |

    {{name}}

    4 |

    {{summary}}

    5 |
    6 |
  • 7 | -------------------------------------------------------------------------------- /template/_collectionNavBox.html: -------------------------------------------------------------------------------- 1 | {{#collection}} 2 |
    3 |

    Collection Info

    4 | 5 |
    6 | {{#collectionPrev}}{{/collectionPrev}}{{^collectionPrev}}{{/collectionPrev}} 7 | {{#collectionNext}}{{/collectionNext}}{{^collectionNext}}{{/collectionNext}} 8 |
    9 |
    10 | {{/collection}} 11 | -------------------------------------------------------------------------------- /template/_collectionbox.html: -------------------------------------------------------------------------------- 1 |
  • 2 | 3 |

    {{name}}

    4 |

    {{summary}}

    5 | 11 |
    12 |
  • 13 | -------------------------------------------------------------------------------- /template/_cookies.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /template/_event.html: -------------------------------------------------------------------------------- 1 | 2 | {{time}} 3 | {{ action }} 4 | {{ user }} 5 | {{ message }} 6 | {{ module }} 7 | {{ version }} 8 | 9 | -------------------------------------------------------------------------------- /template/_footer.html: -------------------------------------------------------------------------------- 1 | 2 | 42 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /template/_gistbox.html: -------------------------------------------------------------------------------- 1 |
  • 2 |

    {{description}}

    3 |

    {{summary}}

    4 | 10 |
    11 | {{>_gistboxTools}} 12 |
  • 13 | -------------------------------------------------------------------------------- /template/_gistboxTools.html: -------------------------------------------------------------------------------- 1 | {{#showTools}} 2 |
    3 |
    4 | {{#ownedCollection}} 5 |
    6 |
    7 | {{/ownedCollection}} 8 | {{^ownedCollection}} 9 |
    10 | {{/ownedCollection}} 11 |
    12 | {{/showTools}} 13 | -------------------------------------------------------------------------------- /template/_gistitems.html: -------------------------------------------------------------------------------- 1 | {{#things}} 2 | {{#isNode}}{{>_nodebox}}{{/isNode}} 3 | {{#isFlow}}{{>_gistbox}}{{/isFlow}} 4 | {{#isCollection}}{{>_collectionbox}}{{/isCollection}} 5 | {{/things}} 6 | {{^things}} 7 |
  •  

     

  • 8 |
  •  

     

  • 9 |
  •  

     

  • 10 | {{/things}} 11 | -------------------------------------------------------------------------------- /template/_gistlist.html: -------------------------------------------------------------------------------- 1 | {{^hideOptions}} 2 |
    3 |
    4 |
    5 |
    6 | {{/hideOptions}} 7 | {{#hideOptions}} 8 |
    9 | 10 | 43 | {{^hideOptions}} 44 |
    45 |
    46 |
    47 | Sort by: 48 | 53 |
    54 | {{/hideOptions}} 55 |
    56 |
      57 | {{>_gistitems}} 58 |
    59 | {{^hideNav}} 60 |
    61 | Prev 62 | 63 | Next 64 |
    65 | {{/hideNav}} 66 | 69 |
    70 | 71 |
    72 | {{^hideOptions}} 73 | 74 |
    75 | {{/hideOptions}} 76 | -------------------------------------------------------------------------------- /template/_header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{#versions}} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {{/versions}} 20 | 21 | {{#pageTitle}} 22 | {{.}} - Node-RED 23 | {{/pageTitle}} 24 | {{^pageTitle}} 25 | Library - Node-RED 26 | {{/pageTitle}} 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
    41 |
    42 | 43 | 52 | 53 |
    54 |
    55 |
    56 | {{>_cookies}} 57 | {{>_toolbar}} 58 | -------------------------------------------------------------------------------- /template/_nodebox.html: -------------------------------------------------------------------------------- 1 |
  • 2 |

    {{name}}

    3 |

    {{description}}

    4 | 12 |
    13 | {{>_gistboxTools}} 14 |
  • 15 | -------------------------------------------------------------------------------- /template/_nodelist.html: -------------------------------------------------------------------------------- 1 | 2 |
    3 |
    4 |
    5 | 6 |
    7 |
    8 | 9 |
    10 |
    11 |
    12 |
    13 |
    14 |
      {{#nodes}}{{>_nodebox}}{{/nodes}} 15 |
    16 |
    17 |
    18 | 19 | 57 | -------------------------------------------------------------------------------- /template/_palettenode.html: -------------------------------------------------------------------------------- 1 |
  • 2 |
    3 | {{#iconFA}}
    {{/iconFA}} 4 | {{#iconUrl}}
    {{/iconUrl}} 5 |
    {{name}}
    6 | {{#hasOutputs}}
    {{/hasOutputs}} 7 | {{#hasInputs}}
    {{/hasInputs}} 8 |
    9 |
  • 10 | -------------------------------------------------------------------------------- /template/_rateTools.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /template/_rulemodal.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
    5 |
    6 | × 7 | 8 | 9 |
    10 |
    11 | 12 | 57 | -------------------------------------------------------------------------------- /template/_scorecardResult.html: -------------------------------------------------------------------------------- 1 |
    2 | {{#pass}} 3 | 4 | {{/pass}} 5 | {{#warn}} 6 | 7 | {{/warn}} 8 | {{#fail}} 9 | 10 | {{/fail}} 11 |
    12 | -------------------------------------------------------------------------------- /template/_tagTools.html: -------------------------------------------------------------------------------- 1 | {{#owned}} 2 | 3 | 49 | {{/owned}} -------------------------------------------------------------------------------- /template/_taglist.html: -------------------------------------------------------------------------------- 1 |
  • {{_id}}
  • 2 | -------------------------------------------------------------------------------- /template/_toolbar.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |
    5 | 6 |
    7 | 8 | 9 | {{^fullsearch}} 10 |
    11 |
      12 |
      13 | 68 | {{/fullsearch}} 69 |
      70 |
      71 | 72 | 73 | {{^sessionuser}} 74 | 75 | {{/sessionuser}} 76 | {{#sessionuser}} 77 | 86 | 94 | {{/sessionuser}} 95 | 96 |
      97 |
      98 |
      99 |
      100 | -------------------------------------------------------------------------------- /template/add.html: -------------------------------------------------------------------------------- 1 | {{>_header}} 2 | 30 | 54 | {{>_footer}} 55 | -------------------------------------------------------------------------------- /template/addCategory.html: -------------------------------------------------------------------------------- 1 | {{>_header}} 2 |
      3 |
       
      4 |
      5 |

      {{#category}}Edit a category{{/category}}{{^category}}Add a category{{/category}}

      6 | 7 |

      8 |

      Give your category a short, descriptive title.

      9 | 10 |
      The title is required
      11 |
      12 |

      13 |

      Describe what the category is about.

      14 |

      This uses GitHub Flavoured Markdown 15 | for formatting. Use the preview tab to see how it looks.

      16 |
      The description must be at least 30 characters long
      17 |
      18 | 19 |
      20 | 21 |
      22 | 23 |

       

      24 | {{^category}}create{{/category}}{{#category}}update{{/category}} category 25 | {{#category}}{{/category}} 26 |
      27 |
      28 | 29 | 30 | 160 | 161 | {{>_footer}} 162 | -------------------------------------------------------------------------------- /template/addCollection.html: -------------------------------------------------------------------------------- 1 | {{>_header}} 2 |
      3 |
       
      4 |
      5 |

      {{#collection}}Edit your collection{{/collection}}{{^collection}}Add a collection{{/collection}}

      6 | 7 |

      8 |

      Give your collection a short, descriptive title.

      9 | 10 |
      The title must be at least 10 characters long
      11 |

      12 |

      Describe what your collection contains.

      13 |

      This uses GitHub Flavoured Markdown 14 | for formatting. Use the preview tab to see how it looks.

      15 |
      The description must be at least 30 characters long
      16 |
      17 | 18 |
      19 | 20 |
      21 | 22 |

       

      23 | {{^collection}}create{{/collection}}{{#collection}}update{{/collection}} collection 24 | {{#collection}}{{/collection}} 25 |
      26 |
      27 | 28 | 29 | 154 | 155 | {{>_footer}} 156 | -------------------------------------------------------------------------------- /template/addNode.html: -------------------------------------------------------------------------------- 1 | {{>_header}} 2 |
      3 |
       
      4 |
      5 |

      Adding a node

      6 |

      Node-RED nodes are packaged as modules and published to 7 | the public npm repository.

      8 |

      Once published to npm, they can be added to the Flow Library using the form below.

      9 |

      To add a node to the library, follow these steps:

      10 |
      11 | 1 12 |
      13 | Create your node and package it as an npm module. 14 |

      Your module must have

      15 |
        16 |
      • a name that follows the project's naming guidelines,
      • 17 |
      • a README.md file that describes what your node does and how to use it,
      • 18 |
      • a package.json file with: 19 |
          20 |
        • a node-red section listing the node files,
        • 21 |
        • and "node-red" in its list of keywords. 22 |
        • 23 |
        24 |
      • 25 |
      26 |
      27 |
      28 | 29 |
      30 | 2 31 |
      32 | Publish your module to the public npm repository. 33 |

      You can use the npm publish command to do this. 34 |

      35 |
      36 |
      37 | 38 |
      39 | 3 40 |
      41 | Add your node to the Flow Library 42 |

      Use this form to tell the Flow Library about your node.

      43 |
      44 | 45 | 46 |
       
      47 | 48 |
      49 |

      50 |
      51 |
      52 |
      53 |
      54 | 81 | 82 | 130 | 131 | {{>_footer}} 132 | -------------------------------------------------------------------------------- /template/categories.html: -------------------------------------------------------------------------------- 1 | {{>_header}} 2 | 3 |
      4 |
      5 | {{#message}} 6 | {{ . }} 7 | 14 | {{/message}} 15 | {{ #isAdmin }} 16 | 17 | {{/isAdmin}} 18 |

      {{{ category.description }}}

      19 |
      20 |
        {{#categories}}{{>_categoryBox}}{{/categories}}
      21 |
      22 | 26 | 27 | {{>_footer}} 28 | -------------------------------------------------------------------------------- /template/category.html: -------------------------------------------------------------------------------- 1 | {{>_header}} 2 | 3 |
      4 |
      5 | {{#message}} 6 | {{ . }} 7 | 14 | {{/message}} 15 |

      {{ category.name }}

      16 | {{ #isAdmin }} 17 | 18 | {{/isAdmin}} 19 |

      {{{ category.description }}}

      20 |
      21 |
      22 |
      23 | {{#query}}{{>_gistlist}}{{/query}} 24 |
      25 | 29 | 30 | {{>_footer}} 31 | -------------------------------------------------------------------------------- /template/events.html: -------------------------------------------------------------------------------- 1 | {{>_header}} 2 |
      3 |
      4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {{#events}}{{>_event}}{{/events}} 14 |
      TimeEventUserMessageModuleVersion
      15 |
      16 |
      17 | {{>_footer}} 18 | -------------------------------------------------------------------------------- /template/gist.html: -------------------------------------------------------------------------------- 1 | {{>_header}} 2 | 3 |
      4 |
      5 |

      {{ description }}

      6 | {{{ readme }}} 7 | {{>_flowViewer}} 8 |
      {{ flow }}
      9 |
      10 |
      11 | {{>_collectionNavBox}} 12 |
      13 |

      Flow Info

      14 |
      Created {{ created_at_since }}
      15 | {{#updated_at_since}}
      Updated {{ updated_at_since }}
      {{/updated_at_since}} 16 |
      Rating: {{#rating}} {{score}} {{count}}{{/rating}}{{^rating}}not yet rated{{/rating}}
      17 | 18 |
      19 |
      20 |

      Owner

      21 | 22 |
      23 | 24 |
      25 |

      Actions

      26 |
      27 | 28 | 29 |
      Rate:
      30 |
      31 | 32 | {{#sessionuser}} 33 | 34 | {{#owned}} 35 |
      36 |    37 |
      blah
      38 |
      39 | 40 | {{/owned}} 41 | {{/sessionuser}} 42 |
      43 | 44 | 45 |
      46 |

      Node Types

      47 | {{#nodeTypes}} 48 | {{#core.length}} 49 |
      Core
      50 |
        51 | {{#core}} 52 |
      • {{type}} (x{{count}})
      • 53 | {{/core}} 54 |
      55 | {{/core.length}} 56 | {{#other.length}} 57 |
      Other
      58 | 63 | {{/other.length}} 64 | {{/nodeTypes}} 65 | 66 |
      67 |
      68 |

      Tags

      69 |
        70 | {{#tags}} 71 |
      • {{.}}
      • 72 | {{/tags}} 73 |
      74 | {{#owned}} 75 | 76 | {{/owned}} 77 | 78 |
      79 |
      80 | Copy this flow JSON to your clipboard and then import into Node-RED using the Import From > Clipboard (Ctrl-I) menu option 81 |
      82 |
      83 |
      84 | {{>_rateTools}} 85 | {{>_tagTools}} 86 | 154 | {{>_footer}} 155 | -------------------------------------------------------------------------------- /template/gistShare.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{#versions}} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {{/versions}} 20 | 21 | {{#pageTitle}} 22 | {{.}} - Node-RED 23 | {{/pageTitle}} 24 | {{^pageTitle}} 25 | Library - Node-RED 26 | {{/pageTitle}} 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 39 | 40 | 41 | {{>_flowViewer}} 42 | 43 | -------------------------------------------------------------------------------- /template/index.html: -------------------------------------------------------------------------------- 1 | {{>_header}} 2 | {{^sessionuser}} 3 |
      4 |
      5 |
      6 |
      7 |

      Node-RED Library

      8 |

      Find new nodes, share your flows and see what other people have done with Node-RED.

      9 |
      10 |
      11 |
      12 | 13 |
      14 |
      15 |
      16 | {{/sessionuser}} 17 | 18 | {{#nodes}} 19 |
      20 | 21 | {{>_gistlist}} 22 |
      23 | {{/nodes}} 24 | 25 | {{#flows}} 26 |
      27 | 28 | {{>_gistlist}} 29 |
      30 | {{/flows}} 31 | 32 | 33 | {{#collections}} 34 |
      35 |

      Recent collections

      see more ({{count}})
      36 | {{>_gistlist}} 37 |
      38 | {{/collections}} 39 | 40 | {{>_footer}} 41 | -------------------------------------------------------------------------------- /template/search.html: -------------------------------------------------------------------------------- 1 | {{>_header}} 2 |
      3 | {{>_gistlist}} 4 |
      5 | {{>_footer}} 6 | 17 | -------------------------------------------------------------------------------- /template/tag.html: -------------------------------------------------------------------------------- 1 | {{>_header}} 2 |
      3 |

      Tag: {{ tag }}

      4 |
      5 |
      6 |
      7 |
        {{#gists}}{{>_gistlist}}{{/gists}}
      8 |
      9 |
      10 | {{>_footer}} 11 | -------------------------------------------------------------------------------- /template/user.html: -------------------------------------------------------------------------------- 1 | {{>_header}} 2 |
      3 |
      4 | {{#user.avatar_url}}{{/user.avatar_url}} 5 | {{^user.avatar_url}}
      {{/user.avatar_url}} 6 |
      7 | {{username}} 8 |
      9 |
      10 |
      11 |
      12 | {{#query}}{{>_gistlist}}{{/query}} 13 |
      14 | {{>_footer}} 15 | -------------------------------------------------------------------------------- /template/userSettings.html: -------------------------------------------------------------------------------- 1 | {{>_header}} 2 |
      3 |
      4 | {{#user.avatar_url}}{{/user.avatar_url}} 5 | {{^user.avatar_url}}
      {{/user.avatar_url}} 6 |
      7 | {{user.login}} 8 |
      9 |
      10 |
      11 | 26 | 27 | 28 | 70 | 84 | 85 | {{>_footer}} 86 | -------------------------------------------------------------------------------- /test/lib/modules_spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable n/no-unpublished-require */ 2 | // eslint-disable-next-line no-unused-vars 3 | const should = require('should') 4 | const sinon = require('sinon') 5 | 6 | const events = require('../../lib/events') 7 | const modules = require('../../lib/modules') 8 | const nodes = require('../../lib/nodes') 9 | const ratings = require('../../lib/ratings') 10 | const sandbox = sinon.createSandbox() 11 | 12 | describe('modules', function () { 13 | afterEach(function () { 14 | sandbox.restore() 15 | }) 16 | 17 | it('#pruneRatings', function (done) { 18 | const list = ['node-red-dashboard', 'node-red-contrib-influxdb', 'node-red-contrib-noble'] 19 | sandbox.stub(ratings, 'getRatedModules').returns(Promise.resolve(list)) 20 | sandbox.stub(nodes, 'get').returns(Promise.resolve({ 21 | _id: 'node-red-dashboard' 22 | })).returns(Promise.resolve({ 23 | _id: 'node-red-contrib-influxdb' 24 | })).returns(Promise.resolve({ 25 | _id: 'node-red-contrib-noble' 26 | })) 27 | 28 | modules.pruneRatings().then(function (results) { 29 | results.should.be.empty() 30 | done() 31 | return null 32 | }).catch(err => { 33 | done(err) 34 | }) 35 | }) 36 | 37 | it('#pruneRatings module removed', function (done) { 38 | const list = ['node-red-dashboard', 'node-red-contrib-influxdb', 'node-red-contrib-noble'] 39 | sandbox.stub(ratings, 'getRatedModules').returns(Promise.resolve(list)) 40 | 41 | const nodesGet = sandbox.stub(nodes, 'get') 42 | nodesGet.withArgs('node-red-dashboard').returns(Promise.resolve({ 43 | _id: 'node-red-dashboard' 44 | })) 45 | nodesGet.withArgs('node-red-contrib-influxdb').returns(Promise.resolve({ 46 | _id: 'node-red-contrib-influxdb' 47 | })) 48 | nodesGet.withArgs('node-red-contrib-noble').returns( 49 | Promise.reject(new Error('node not found: node-red-contrib-noble'))) 50 | 51 | sandbox.stub(ratings, 'removeForModule').returns(Promise.resolve()) 52 | sandbox.stub(events, 'add').returns(Promise.resolve()) 53 | 54 | modules.pruneRatings().then(function (results) { 55 | results.should.have.length(1) 56 | results[0].should.eql({ 57 | state: 'fulfilled', 58 | value: 'node-red-contrib-noble ratings removed' 59 | }) 60 | done() 61 | return null 62 | }).catch(err => { 63 | done(err) 64 | }) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /test/lib/ratings_spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable n/no-unpublished-require */ 2 | // eslint-disable-next-line no-unused-vars 3 | const should = require('should') 4 | const sinon = require('sinon') 5 | 6 | const db = require('../../lib/db') 7 | const ratings = require('../../lib/ratings') 8 | const sandbox = sinon.createSandbox() 9 | 10 | // With the move to the async mongodb client, how we mock the db module needs to change 11 | // I haven't figured it all out yet, so keeping this spec in place for the time being 12 | 13 | describe.skip('ratings', function () { 14 | before(async function () { 15 | return db.init() 16 | }) 17 | afterEach(function () { 18 | sandbox.restore() 19 | }) 20 | 21 | it('#save', function (done) { 22 | const dbUpdate = sandbox.stub(db.ratings, 'update').yields(null) 23 | 24 | const testRating = { 25 | user: 'testuser', 26 | module: 'node-red-dashboard', 27 | time: new Date(), 28 | rating: 4 29 | } 30 | 31 | ratings.save(testRating).then(function () { 32 | sinon.assert.calledWith(dbUpdate, 33 | { module: testRating.module, user: testRating.user }, testRating, { upsert: true }) 34 | done() 35 | }).catch(function (err) { 36 | done(err) 37 | }) 38 | }) 39 | 40 | it('#remove', function (done) { 41 | const dbRemove = sandbox.stub(db.ratings, 'remove').yields(null) 42 | const testRating = { 43 | user: 'testuser', 44 | module: 'node-red-dashboard' 45 | } 46 | ratings.remove(testRating).then(function () { 47 | sinon.assert.calledWith(dbRemove, testRating) 48 | done() 49 | }).catch(function (err) { 50 | done(err) 51 | }) 52 | }) 53 | 54 | it('#get', function (done) { 55 | const totalRating = [{ _id: 'node-red-dashboard', total: 19, count: 2 }] 56 | const userRating = { 57 | user: 'test', 58 | module: 'node-red-dashboard', 59 | rating: 4, 60 | version: '2.6.1', 61 | time: new Date('2018-01-15T00:34:27.998Z') 62 | } 63 | 64 | sandbox.stub(db.ratings, 'aggregate').yields(null, 65 | totalRating 66 | ) 67 | 68 | sandbox.stub(db.ratings, 'findOne').yields(null, userRating) 69 | 70 | ratings.get('node-red-dashboard', 'test').then(function (found) { 71 | found.should.eql({ 72 | module: 'node-red-dashboard', 73 | total: 19, 74 | count: 2, 75 | userRating: { 76 | user: 'test', 77 | module: 'node-red-dashboard', 78 | rating: 4, 79 | version: '2.6.1', 80 | time: new Date('2018-01-15T00:34:27.998Z') 81 | } 82 | }) 83 | done() 84 | }).catch(function (err) { 85 | done(err) 86 | }) 87 | }) 88 | 89 | it('#get no user rating', function (done) { 90 | sandbox.stub(db.ratings, 'aggregate').yields(null, 91 | [{ _id: 'node-red-dashboard', total: 19, count: 2 }] 92 | ) 93 | const foundRating = { 94 | user: 'test', 95 | module: 'node-red-dashboard', 96 | rating: 4, 97 | version: '2.6.1', 98 | time: new Date('2018-01-15T00:34:27.998Z') 99 | } 100 | 101 | const dbFindOne = sandbox.stub(db.ratings, 'findOne').yields(null, 102 | foundRating 103 | ) 104 | 105 | ratings.get('node-red-dashboard').then(function (found) { 106 | found.should.eql({ 107 | module: 'node-red-dashboard', 108 | total: 19, 109 | count: 2 110 | }) 111 | sinon.assert.notCalled(dbFindOne) 112 | done() 113 | }).catch(function (err) { 114 | done(err) 115 | }) 116 | }) 117 | 118 | it('#getRatedModules', function (done) { 119 | const list = ['node-red-dashboard', 'node-red-contrib-influxdb', 'node-red-contrib-noble'] 120 | sandbox.stub(db.ratings, 'distinct').yields(null, list) 121 | 122 | ratings.getRatedModules().then(function (modList) { 123 | modList.should.eql(list) 124 | done() 125 | }) 126 | }) 127 | 128 | it('#removeForModule', function (done) { 129 | const dbRemove = sandbox.stub(db.ratings, 'remove').yields(null) 130 | 131 | ratings.removeForModule('node-red-dashboard').then(function () { 132 | sinon.assert.calledWith(dbRemove, { module: 'node-red-dashboard' }) 133 | done() 134 | }) 135 | }) 136 | }) 137 | --------------------------------------------------------------------------------