From 4e25bf8cd7c0e28c60b79641a7f1507cd97a0db2 Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 7 May 2022 23:09:03 +1000 Subject: [PATCH] implement global toggle implement add/delete domain implement add/delete cluster implement add/delete map entries implement NProgress for when loading from api all works without js too --- api.js | 28 ++++++++ app.js | 140 ---------------------------------------- components/MapRow.js | 4 +- controllers/account.js | 33 ++++++---- controllers/clusters.js | 11 ++-- controllers/domains.js | 16 ++++- controllers/maps.js | 4 +- next.config.js | 2 +- package-lock.json | 11 ++++ package.json | 4 +- pages/_app.js | 44 +++++++------ pages/account.js | 30 +++++---- pages/clusters.js | 96 +++++++++++++++++++++++++++ pages/domains.js | 99 ++++++++++++++++++++++++---- pages/login.js | 9 +-- pages/map/[name].js | 79 ++++++++++++++++------- pages/register.js | 2 +- router.js | 26 ++++---- 18 files changed, 386 insertions(+), 252 deletions(-) create mode 100644 api.js delete mode 100644 app.js create mode 100644 pages/clusters.js diff --git a/api.js b/api.js new file mode 100644 index 0000000..621c6d0 --- /dev/null +++ b/api.js @@ -0,0 +1,28 @@ +import NProgress from 'nprogress'; + +export default async function ApiCall(route, method, body, stateCallback, finishProgress) { + try { + const options = { + method, + }; + if (body != null) { + options.body = body; + options.headers = { 'Content-Type': 'application/json' }; + } + console.log(options) + NProgress.start(); + let response = await fetch(route, options) + .then(res => res.json()); + console.log(response) + stateCallback && stateCallback(response); + } catch(e) { + console.error(e); + } finally { + if (finishProgress != null) { + NProgress.set(finishProgress); + } else { + NProgress.done(true); + } + return null; + } +} diff --git a/app.js b/app.js deleted file mode 100644 index e068f72..0000000 --- a/app.js +++ /dev/null @@ -1,140 +0,0 @@ -process - .on('uncaughtException', console.error) - .on('unhandledRejection', console.error); - -//Dependencies -const express = require('express'); -const bodyParser = require('body-parser'); -const cookieParser = require('cookie-parser') -const dotenv = require('dotenv'); -dotenv.config({ path: '.env' }); -const HAProxy = require('@fatchan/haproxy-sdk'); -const session = require('express-session'); -const MongoStore = require('connect-mongo'); -const csrf = require('csurf'); -const db = require('./db.js'); - -//Controllers -const mapsController = require('./controllers/maps'); -const accountsController = require('./controllers/accounts'); -const domainsController = require('./controllers/domains'); - -//Express setup -const app = express(); -app.set('query parser', 'simple'); -app.use(bodyParser.json({ extended: false })); // for parsing application/json -app.use(bodyParser.urlencoded({ extended: false })); // for parsing application/x-www-form-urlencoded -app.use(cookieParser(process.env.COOKIE_SECRET)); -app.disable('x-powered-by'); -app.set('view engine', 'pug'); -app.set('views', './views/pages'); -app.enable('view cache'); -app.set('trust proxy', 1); - -//template locals -app.locals.mapValueNames = { '0': 'None', '1': 'Proof-of-work', '2': 'hCaptcha' }; -app.locals.fMap = require('./util.js').fMap; - -async function run() { - //Session & auth - await db.connect(); - const sessionStore = session({ - secret: process.env.COOKIE_SECRET, - store: MongoStore.create({ mongoUrl: process.env.DB_URL }), - resave: false, - saveUninitialized: false, - rolling: true, - cookie: { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', - maxAge: 1000 * 60 * 60 * 24 * 7, //week - } - }); - const useSession = (req, res, next) => { - sessionStore(req, res, next); - } - const fetchSession = async (req, res, next) => { - if (req.session.user) { - const account = await db.db.collection('accounts').findOne({_id:req.session.user}); - if (account) { - res.locals.user = { - username: account._id, - domains: account.domains, - clusters: account.clusters, - activeCluster: account.activeCluster, - }; - return next(); - } - req.session.destroy(); - } - next(); - }; - const checkSession = (req, res, next) => { - if (!res.locals.user) { - return res.redirect('/login'); - } - next(); - }; - const csrfMiddleware = csrf(); - - //HAProxy-sdk middleware - const useHaproxy = (req, res, next) => { - if (res.locals.user.clusters.length === 0) { - return next(); - } - try { - //uses cluster from account - res.locals.haproxy = new HAProxy(res.locals.user.clusters[res.locals.user.activeCluster]); - next(); - } catch (e) { - res.status(500).send(e); - } - }; - - const hasCluster = (req, res, next) => { - if (res.locals.user.clusters.length > 0) { - return next(); - } - res.redirect('/clusters'); - } - - //static - app.use('/static', express.static('static')); - app.use('/static/css', express.static('node_modules/bootstrap/dist/css')); - - //unauthed pages - app.get('/', useSession, fetchSession, accountsController.homePage); - app.get('/login', useSession, fetchSession, accountsController.loginPage); - app.get('/register', useSession, fetchSession, accountsController.registerPage); - - //register/login/logout - app.post('/login', useSession, accountsController.login); - app.post('/logout', useSession, accountsController.logout); - app.post('/register', useSession, accountsController.register); - - //authed pages that dont require a cluster - app.get('/account', useSession, fetchSession, checkSession, useHaproxy, csrfMiddleware, accountsController.accountPage); - app.get('/domains', useSession, fetchSession, checkSession, useHaproxy, csrfMiddleware, domainsController.domainsPage); - app.get('/clusters', useSession, fetchSession, checkSession, useHaproxy, csrfMiddleware, accountsController.clustersPage); - - //authed pages that useHaproxy - const clusterRouter = express.Router({ caseSensitive: true }); - clusterRouter.post('/global/toggle', accountsController.globalToggle); - clusterRouter.post('/cluster', accountsController.setCluster); - clusterRouter.post('/cluster/add', accountsController.addCluster); - clusterRouter.post('/cluster/delete', accountsController.deleteClusters); - clusterRouter.post('/domain/add', domainsController.addDomain); - clusterRouter.post('/domain/delete', domainsController.deleteDomain); - clusterRouter.get(`/maps/:name(${process.env.BLOCKED_MAP_NAME}|${process.env.MAINTENANCE_MAP_NAME}|${process.env.WHITELIST_MAP_NAME}|${process.env.BLOCKED_MAP_NAME}|${process.env.DDOS_MAP_NAME}|${process.env.HOSTS_MAP_NAME})`, mapsController.getMapHtml); - clusterRouter.post(`/maps/:name(${process.env.BLOCKED_MAP_NAME}|${process.env.MAINTENANCE_MAP_NAME}|${process.env.WHITELIST_MAP_NAME}|${process.env.DDOS_MAP_NAME}|${process.env.HOSTS_MAP_NAME})/add`, mapsController.patchMapForm); //add to MAP - clusterRouter.post(`/maps/:name(${process.env.BLOCKED_MAP_NAME}|${process.env.MAINTENANCE_MAP_NAME}|${process.env.WHITELIST_MAP_NAME}|${process.env.DDOS_MAP_NAME}|${process.env.HOSTS_MAP_NAME})/delete`, mapsController.deleteMapForm); //delete from MAP - app.use('/', useSession, fetchSession, checkSession, useHaproxy, hasCluster, csrfMiddleware, clusterRouter); - - app.listen(8080, () => { - console.log('Running at http://localhost:8080 in %s mode', process.env.NODE_ENV); - }); - -} - -run(); diff --git a/components/MapRow.js b/components/MapRow.js index 3620630..dbec1a8 100644 --- a/components/MapRow.js +++ b/components/MapRow.js @@ -1,13 +1,13 @@ import Link from 'next/link'; -export default function MapRow({ name, row, csrf, showValues, mapValueNames }) { +export default function MapRow({ name, row, csrf, showValues, mapValueNames, onDeleteSubmit }) { const [id, key, value] = row.split(' '); return ( -
+ diff --git a/controllers/account.js b/controllers/account.js index c1b2499..817f4db 100644 --- a/controllers/account.js +++ b/controllers/account.js @@ -3,7 +3,7 @@ const db = require('../db.js'); const { validClustersString, makeArrayIfSingle, extractMap } = require('../util.js'); /** - * account data + * account page data shared between html/json routes */ exports.accountData = async (req, res, next) => { let maps = [] @@ -39,20 +39,26 @@ exports.accountData = async (req, res, next) => { } }; -// SSR page / first loac +/** + * GET /account + * account page html + */ exports.accountPage = async (app, req, res, next) => { const data = await exports.accountData(req, res, next); - return app.render(req, res, '/account', data); + return app.render(req, res, '/account', { ...data, user: res.locals.user }); } -// JSON for CSR / later page switching +/** + * GET /account.json + * account page json data + */ exports.accountJson = async (req, res, next) => { const data = await exports.accountData(req, res, next); return res.json({ ...data, user: res.locals.user }); } /** - * POST /global/toggle + * POST /forms/global/toggle * toggle global ACL */ exports.globalToggle = async (req, res, next) => { @@ -85,11 +91,11 @@ exports.globalToggle = async (req, res, next) => { }; /** - * POST /login + * POST /forms/login * login */ exports.login = async (req, res) => { - const username = req.body.username; //.toLowerCase(); + const username = req.body.username.toLowerCase(); const password = req.body.password; const account = await db.db.collection('accounts').findOne({_id:username}); if (!account) { @@ -104,18 +110,18 @@ exports.login = async (req, res) => { }; /** - * POST /register + * POST /forms/register * regiser */ exports.register = async (req, res) => { - const username = req.body.username; //.toLowerCase(); + const username = req.body.username.toLowerCase(); const password = req.body.password; const rPassword = req.body.repeat_password; if (!username || typeof username !== "string" || username.length === 0 || !password || typeof password !== "string" || password.length === 0 || !rPassword || typeof rPassword !== "string" || rPassword.length === 0) { - //todo: length limits, copy jschan input validator + //todo: length limits, make jschan input validator LGPL lib and use here return res.status(400).send('Invalid inputs'); } @@ -123,7 +129,7 @@ exports.register = async (req, res) => { return res.status(400).send('Passwords did not match'); } - const existingAccount = await db.db.collection('accounts').findOne({ _id: req.body.username }); + const existingAccount = await db.db.collection('accounts').findOne({ _id: username }); if (existingAccount) { return res.status(409).send('Account already exists with that username'); } @@ -132,7 +138,8 @@ exports.register = async (req, res) => { await db.db.collection('accounts') .insertOne({ - _id: req.body.username, + _id: username, + displayName: req.body.username, passwordHash: passwordHash, domains: [], clusters: [], @@ -144,7 +151,7 @@ exports.register = async (req, res) => { }; /** - * POST /logout + * POST /forms/logout * logout */ exports.logout = (req, res) => { diff --git a/controllers/clusters.js b/controllers/clusters.js index 26d8bcb..ca9d978 100644 --- a/controllers/clusters.js +++ b/controllers/clusters.js @@ -2,13 +2,16 @@ const db = require('../db.js'); const { validClustersString, makeArrayIfSingle, extractMap } = require('../util.js'); exports.clustersPage = async (app, req, res, next) => { - return res.render('clusters', { + return app.render(req, res, '/clusters', { csrf: req.csrfToken(), }); }; -exports.clustersJson = async (app, req, res, next) => { - return res.json({ user: res.locals.user }); +exports.clustersJson = async (req, res, next) => { + return res.json({ + csrf: req.csrfToken(), + user: res.locals.user, + }); } /** @@ -52,7 +55,7 @@ exports.setCluster = async (req, res, next) => { if (res.locals.user.username !== "admin") { return res.status(403).send('only admin can change cluster'); } - if (!req.body || !req.body.cluster) { + if (req.body == null || req.body.cluster == null) { return res.status(400).send('invalid cluster'); } req.body.cluster = parseInt(req.body.cluster, 10) || 0; diff --git a/controllers/domains.js b/controllers/domains.js index 6a8cf37..b67ffb8 100644 --- a/controllers/domains.js +++ b/controllers/domains.js @@ -6,12 +6,23 @@ const { deleteFromMap } = require('../util.js'); * GET /domains * domains page */ -exports.domainsPage = async (req, res) => { - return res.render('domains', { +exports.domainsPage = async (app, req, res) => { + return app.render(req, res, '/domains', { csrf: req.csrfToken(), }); }; +/** + * GET /domains.json + * domains json data + */ +exports.domainsJSON = async (req, res) => { + return res.json({ + csrf: req.csrfToken(), + user: res.locals.user, + }); +}; + /** * POST /domain/add * add domain @@ -48,6 +59,7 @@ exports.deleteDomain = async (req, res) => { } //will fail if domain is only in the hosts map for a different cluster, so we wont do it (for now) + //but will cause permission problems "invalid input" when trying to delete it from the other cluster later... hmmm... //await deleteFromMap(res.locals.haproxy, process.env.HOSTS_MAP_NAME, [req.body.domain]); await db.db.collection('accounts') diff --git a/controllers/maps.js b/controllers/maps.js index f99349a..e575fa1 100644 --- a/controllers/maps.js +++ b/controllers/maps.js @@ -114,7 +114,7 @@ exports.deleteMapForm = async (req, res, next) => { } await deleteFromMap(res.locals.haproxy, req.params.name, req.body.key); - return res.redirect(`/maps/${req.params.name}`); + return res.redirect(`/map/${req.params.name}`); } catch (e) { return next(e); } @@ -254,7 +254,7 @@ exports.patchMapForm = async (req, res, next) => { const mapId = await getMapId(res.locals.haproxy, req.params.name); await res.locals.haproxy .addMap(mapId.index, req.body.key, value); - return res.redirect(`/maps/${req.params.name}`); + return res.redirect(`/map/${req.params.name}`); } catch (e) { return next(e); } diff --git a/next.config.js b/next.config.js index 75ed22b..c411fd4 100644 --- a/next.config.js +++ b/next.config.js @@ -1,3 +1,3 @@ module.exports = { - useFileSystemPublicRoutes: false, + /* config options here */ } diff --git a/package-lock.json b/package-lock.json index 3f142df..90691e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "gulp": "^4.0.2", "ip6addr": "^0.2.5", "next": "^12.1.6", + "nprogress": "^0.2.0", "react": "^18.1.0", "react-dom": "^18.1.0" } @@ -3548,6 +3549,11 @@ "set-blocking": "^2.0.0" } }, + "node_modules/nprogress": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", + "integrity": "sha1-y480xTIT2JVyP8urkH6UIq28r7E=" + }, "node_modules/number-is-nan": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", @@ -8324,6 +8330,11 @@ "set-blocking": "^2.0.0" } }, + "nprogress": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", + "integrity": "sha1-y480xTIT2JVyP8urkH6UIq28r7E=" + }, "number-is-nan": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", diff --git a/package.json b/package.json index 826bb7f..8afb0fb 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,7 @@ "scripts": { "dev": "node server.js", "build": "next build", - "start": "next start", - "export": "next export" + "start": "NODE_ENV=production node server.js" }, "keywords": [], "author": "Thomas Lynch (fatchan) ", @@ -27,6 +26,7 @@ "gulp": "^4.0.2", "ip6addr": "^0.2.5", "next": "^12.1.6", + "nprogress": "^0.2.0", "react": "^18.1.0", "react-dom": "^18.1.0" } diff --git a/pages/_app.js b/pages/_app.js index 1606499..27a3498 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -1,25 +1,29 @@ import 'bootstrap/dist/css/bootstrap.css'; +import NProgress from 'nprogress'; import Layout from '../components/Layout.js'; +import "nprogress/nprogress.css"; export default function App({ Component, pageProps }) { - return ( - - - ); + return ( + + + + + ); } diff --git a/pages/account.js b/pages/account.js index bc12061..7a18ead 100644 --- a/pages/account.js +++ b/pages/account.js @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import Head from 'next/head'; import Link from 'next/link'; import MapLink from '../components/MapLink.js'; +import ApiCall from '../api.js'; const Account = (props) => { @@ -9,17 +10,12 @@ const Account = (props) => { React.useEffect(() => { if (!accountData.user) { - async function getAccount() { - const response = await fetch('/account.json') - .then(res => res.json()); - setAccountData(response); - } - getAccount(); + ApiCall('/account.json', 'GET', null, setAccountData); } }, []); if (!accountData.user) { - return <>Loading...; + return <>Loading...; //todo: page with animated css placeholder boxes } const { user, maps, acls, globalAcl, csrf } = accountData; @@ -33,9 +29,21 @@ const Account = (props) => { // Links to each map and bubble/pill for map counts const mapLinks = maps.map((map, i) => ); + async function switchCluster(e) { + e.preventDefault(); + await ApiCall('/forms/cluster', 'POST', JSON.stringify({ _csrf: csrf, cluster: nextCluster }), null, 0.5); + await ApiCall('/account.json', 'GET', null, setAccountData); + } + + async function toggleGlobal(e) { + e.preventDefault(); + await ApiCall('/forms/global/toggle', 'POST', JSON.stringify({ _csrf: csrf }), null, 0.5); + await ApiCall('/account.json', 'GET', null, setAccountData); + } + return ( <> - + Account @@ -53,7 +61,7 @@ const Account = (props) => { Global Override - + @@ -84,12 +92,12 @@ const Account = (props) => {
Servers ({user.clusters[user.activeCluster].split(',').length}) - : {user.clusters[user.activeCluster]} + : {user.clusters[user.activeCluster].split(',').map(x => x.substring(0, x.length/2)+'...').join(', ')}
-
+ diff --git a/pages/clusters.js b/pages/clusters.js new file mode 100644 index 0000000..76e081f --- /dev/null +++ b/pages/clusters.js @@ -0,0 +1,96 @@ +import React, { useState } from 'react'; +import Head from 'next/head'; +import Link from 'next/link'; +import BackButton from '../components/BackButton.js' +import ApiCall from '../api.js' + +export default function Clusters(props) { + + const [accountData, setAccountData] = useState(props); + + React.useEffect(() => { + if (!accountData.user) { + ApiCall('/account.json', 'GET', null, setAccountData); + } + }, []); + + if (!accountData.user) { + return <>Loading...; //TODO: page with animated css placeholder boxes + } + + const { user, maps, acls, globalAcl, csrf } = accountData; + + async function addCluster(e) { + e.preventDefault(); + await ApiCall('/forms/cluster/add', 'POST', JSON.stringify({ _csrf: csrf, cluster: e.target.cluster.value }), null, 0.5); + await ApiCall('/account.json', 'GET', null, setAccountData); + } + + async function deleteCluster(e) { + e.preventDefault(); + await ApiCall('/forms/cluster/delete', 'POST', JSON.stringify({ _csrf: csrf, cluster: e.target.cluster.value }), null, 0.5); + await ApiCall('/account.json', 'GET', null, setAccountData); + } + + const domainList = user.clusters.map(c => { + //TODO: refactor, to component + return ( + + + + + + +
+ + + {c} + + + ); + }) + + return ( + <> + + Clusters + + +
+ Clusters ({user.clusters.length}): +
+ + {/* Clusters table */} +
+ + + + {domainList} + + {/* Add new domain form */} + + + + + +
+
+ + + + +
+
+
+ + {/* back to account */} + + + + ); + +}; + +export async function getServerSideProps({ req, res, query, resolvedUrl, locale, locales, defaultLocale}) { + return { props: { user: res.locals.user || null, ...query } } +} diff --git a/pages/domains.js b/pages/domains.js index 6c6783d..27f412c 100644 --- a/pages/domains.js +++ b/pages/domains.js @@ -1,19 +1,96 @@ +import React, { useState } from 'react'; import Head from 'next/head'; import Link from 'next/link'; import BackButton from '../components/BackButton.js' +import ApiCall from '../api.js' -const Domains = () => ( - <> - - Domains - +export default function Domains(props) { -

TODO: domains

+ const [accountData, setAccountData] = useState(props); - {/* back to account */} - + React.useEffect(() => { + if (!accountData.user) { + ApiCall('/account.json', 'GET', null, setAccountData); + } + }, []); - -); + if (!accountData.user) { + return <>Loading...; //TODO: page with animated css placeholder boxes + } -export default Domains; + const { user, maps, acls, globalAcl, csrf } = accountData; + + async function addDomain(e) { + e.preventDefault(); + await ApiCall('/forms/domain/add', 'POST', JSON.stringify({ _csrf: csrf, domain: e.target.domain.value }), null, 0.5); + await ApiCall('/account.json', 'GET', null, setAccountData); + } + + async function deleteDomain(e) { + e.preventDefault(); + await ApiCall('/forms/domain/delete', 'POST', JSON.stringify({ _csrf: csrf, domain: e.target.domain.value }), null, 0.5); + await ApiCall('/account.json', 'GET', null, setAccountData); + } + + const domainList = user.domains.map(d => { + //TODO: refactor, to component + return ( + + +
+ + + +
+ + + {d} + + + ); + }) + + return ( + <> + + Domains + + +
+ Your Domains ({user.domains.length}): +
+ + {/* Domains table */} +
+ + + + {domainList} + + {/* Add new domain form */} + + + + + +
+
+ + + + +
+
+
+ + {/* back to account */} + + + + ); + +}; + +export async function getServerSideProps({ req, res, query, resolvedUrl, locale, locales, defaultLocale}) { + return { props: { user: res.locals.user || null, ...query } } +} diff --git a/pages/login.js b/pages/login.js index f32fecb..ac4d88e 100644 --- a/pages/login.js +++ b/pages/login.js @@ -1,13 +1,13 @@ import Head from 'next/head'; -const Login = ({ isLoggedIn }) => ( +const Login = () => ( <> Login
Login
-
+