From 5adfe708fb8ece4b544da633b7c675d4f77db4ab Mon Sep 17 00:00:00 2001 From: johackim Date: Wed, 24 Apr 2024 02:02:53 +0200 Subject: [PATCH] refactor: separate utility functions and business logic --- .github/workflows/test.yml | 20 ++++++++++++++++ Dockerfile | 4 ++-- __tests__/block.spec.js | 41 ++++++++++++++++--------------- __tests__/commands.spec.js | 10 -------- __tests__/daemon.spec.js | 4 ++-- __tests__/shield.spec.js | 49 ++++++++++++++++---------------------- jest.setup.js | 11 +++++++++ package.json | 9 +++---- src/block.js | 8 +++---- src/commands.js | 4 ++-- src/config.js | 26 +++++++++++++------- src/shield.js | 21 ++++++++-------- src/socket.io.js | 12 +++++----- src/socket.js | 42 ++++++++++++++++++++++++++------ src/whitelist.js | 8 +++---- 15 files changed, 161 insertions(+), 108 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 jest.setup.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1016a38 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Test + +on: + push: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-node@v3 + with: + node-version: 20 + + - name: Install + run: yarn + + - name: Test + run: npm test diff --git a/Dockerfile b/Dockerfile index 8e07d10..d1cf633 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18 AS build +FROM node:20 AS build WORKDIR /app @@ -12,7 +12,7 @@ RUN rm -rf node_modules RUN yarn install --prod --ignore-optional -FROM gcr.io/distroless/nodejs:18 +FROM gcr.io/distroless/nodejs20 WORKDIR /app diff --git a/__tests__/block.spec.js b/__tests__/block.spec.js index a6c1ff5..5c15bd1 100644 --- a/__tests__/block.spec.js +++ b/__tests__/block.spec.js @@ -1,4 +1,4 @@ -import { config } from '../src/config'; +import { config, readConfig } from '../src/config'; import { blockDistraction, isWithinTimeRange, @@ -7,12 +7,6 @@ import { isDistractionBlocked, } from '../src/block'; -import('../src/socket'); - -jest.mock('child_process', () => ({ - execSync: jest.fn().mockImplementation(() => false), -})); - beforeEach(() => { config.blocklist = []; config.whitelist = []; @@ -41,13 +35,13 @@ test('Should check if a time is within an interval', async () => { }); test('Should block a distraction', async () => { - blockDistraction({ name: 'example.com' }); + await blockDistraction({ name: 'example.com' }); expect(isDistractionBlocked('example.com')).toEqual(true); }); test('Should block a distraction with a duration', async () => { - blockDistraction({ name: 'twitter.com', time: '2m' }); + await blockDistraction({ name: 'twitter.com', time: '2m' }); expect(isDistractionBlocked('twitter.com')).toBe(true); expect(config.blocklist).toEqual([{ name: 'twitter.com', time: '2m', timeout: expect.any(Number) }]); @@ -57,20 +51,20 @@ test('Should block a distraction with a time-based interval', async () => { const currentDate = new Date('2021-01-01T12:00:00Z'); jest.spyOn(global, 'Date').mockImplementation(() => currentDate); - blockDistraction({ name: 'example.com', time: '0h-23h' }); + await blockDistraction({ name: 'example.com', time: '0h-23h' }); expect(isDistractionBlocked('example.com')).toBe(true); }); test('Should block a specific subdomain', async () => { - blockDistraction({ name: 'www.example.com' }); + await blockDistraction({ name: 'www.example.com' }); expect(isDistractionBlocked('www.example.com')).toBe(true); expect(isDistractionBlocked('example.com')).toBe(false); }); test('Should block all subdomains of a domain with a wildcard', async () => { - blockDistraction({ name: '*.example.com' }); + await blockDistraction({ name: '*.example.com' }); expect(isDistractionBlocked('www.example.com')).toBe(true); }); @@ -79,13 +73,13 @@ test('Should block all subdomains of a domain with a wildcard & a time-based int const currentDate = new Date('2021-01-01T12:00:00Z'); jest.spyOn(global, 'Date').mockImplementation(() => currentDate); - blockDistraction({ name: '*.example.com', time: '0h-19h' }); + await blockDistraction({ name: '*.example.com', time: '0h-19h' }); expect(isDistractionBlocked('www.example.com')).toBe(true); }); test('Should block all domains with *.*', async () => { - blockDistraction({ name: '*.*' }); + await blockDistraction({ name: '*.*' }); expect(isDistractionBlocked('example.com')).toBe(true); }); @@ -94,7 +88,7 @@ test('Should not block an app with a time-based interval', async () => { const currentDate = new Date('2021-01-01T22:00:00Z'); jest.spyOn(global, 'Date').mockImplementation(() => currentDate); - blockDistraction({ name: 'chromium', time: '0h-20h' }); + await blockDistraction({ name: 'chromium', time: '0h-20h' }); expect(isDistractionBlocked('chromium')).toBe(false); }); @@ -103,21 +97,21 @@ test('Should not block a subdomain of a domain with a wildcard & a time-based in const currentDate = new Date('2021-01-01T20:00:00Z'); jest.spyOn(global, 'Date').mockImplementation(() => currentDate); - blockDistraction({ name: '*.example.com', time: '0h-19h' }); + await blockDistraction({ name: '*.example.com', time: '0h-19h' }); expect(isDistractionBlocked('www.example.com')).toBe(false); }); test('Should not block apps if *.* is in the blocklist', async () => { - blockDistraction({ name: '*.*' }); + await blockDistraction({ name: '*.*' }); expect(isDistractionBlocked('chromium')).toBe(false); }); test('Should unblock a distraction', async () => { - blockDistraction({ name: 'example.com' }); + await blockDistraction({ name: 'example.com' }); - unblockDistraction({ name: 'example.com' }); + await unblockDistraction({ name: 'example.com' }); expect(isDistractionBlocked('example.com')).toBe(false); }); @@ -132,3 +126,12 @@ test('Should run isDistractionBlocked in less than 150ms with a large blocklist' expect(end[1] / 1000000).toBeLessThan(150); }); + +test('Should update date when blocking a distraction', async () => { + const currentDate = (new Date()).getTime(); + + await blockDistraction({ name: 'example.com' }); + + const date = new Date(readConfig().date).getTime(); + expect(date).toBeGreaterThanOrEqual(currentDate); +}); diff --git a/__tests__/commands.spec.js b/__tests__/commands.spec.js index c688cbc..44a72d5 100644 --- a/__tests__/commands.spec.js +++ b/__tests__/commands.spec.js @@ -1,16 +1,6 @@ import { config } from '../src/config'; import { helpCmd, versionCmd, blockCmd, whitelistCmd, unblockCmd, shieldCmd } from '../src/commands'; -jest.mock('net', () => ({ - createConnection: jest.fn().mockReturnThis(), - write: jest.fn(), - end: jest.fn(), -})); - -jest.mock('child_process', () => ({ - execSync: jest.fn().mockImplementation(() => false), -})); - beforeEach(() => { process.argv = []; config.blocklist = []; diff --git a/__tests__/daemon.spec.js b/__tests__/daemon.spec.js index a0faed4..3360181 100644 --- a/__tests__/daemon.spec.js +++ b/__tests__/daemon.spec.js @@ -1,5 +1,5 @@ import fs from 'fs'; -import { config } from '../src/config'; +import { config, readConfig } from '../src/config'; import { getRunningApps } from '../src/utils'; import { blockDistraction } from '../src/block'; import { handleAppBlocking, handleTimeout, updateResolvConf } from '../src/daemon'; @@ -47,5 +47,5 @@ test('Should remove a distraction from blocklist if timeout is reached', async ( handleTimeout(); - expect(config.blocklist).toEqual([{ name: 'chromium' }]); + expect(readConfig().blocklist).toEqual([{ name: 'chromium' }]); }); diff --git a/__tests__/shield.spec.js b/__tests__/shield.spec.js index 6f50f75..46a565e 100644 --- a/__tests__/shield.spec.js +++ b/__tests__/shield.spec.js @@ -1,57 +1,50 @@ -import { config } from '../src/config'; -import { enableShieldMode, disableShieldMode } from '../src/shield'; +import { readConfig } from '../src/config'; import { whitelistDistraction } from '../src/whitelist'; +import { enableShieldMode, disableShieldMode } from '../src/shield'; import { blockDistraction, unblockDistraction } from '../src/block'; -jest.mock('child_process', () => ({ - execSync: jest.fn().mockImplementation(() => false), -})); - beforeEach(() => { - config.blocklist = []; - config.whitelist = []; - config.shield = false; - config.password = 'ulysse'; jest.spyOn(console, 'log').mockImplementation(() => {}); }); test('Should enable shield mode', async () => { - enableShieldMode('ulysse'); + await enableShieldMode('ulysse'); - const passwordHash = 'd97e609b03de7506d4be3bee29f2431b40e375b33925c2f7de5466ce1928da1b'; - expect(config.passwordHash).toBe(passwordHash); - expect(config.shield).toBe(true); + const { password, passwordHash, shield } = readConfig(); + expect(password).toBeUndefined(); + expect(passwordHash).toBe('d97e609b03de7506d4be3bee29f2431b40e375b33925c2f7de5466ce1928da1b'); + expect(shield).toBe(true); }); test('Should disable shield mode', async () => { - enableShieldMode('ulysse'); + await enableShieldMode('ulysse'); - disableShieldMode('ulysse'); + await disableShieldMode('ulysse'); - expect(config.shield).toBe(false); + expect(readConfig().shield).toBe(false); + expect(readConfig().passwordHash).toBeUndefined(); }); test('Should not disable shield mode if bad password', async () => { - enableShieldMode('ulysse'); - - disableShieldMode('badpassword'); + await enableShieldMode('ulysse'); + await disableShieldMode('badpassword'); - expect(config.shield).toBe(true); + expect(readConfig().shield).toBe(true); }); test('Should not unblock a distraction if shield mode is enabled', async () => { - blockDistraction({ name: 'example.com' }); - enableShieldMode('ulysse'); + await blockDistraction({ name: 'example.com' }); + await enableShieldMode('ulysse'); - unblockDistraction({ name: 'example.com' }); + await unblockDistraction({ name: 'example.com' }); - expect(config.blocklist).toEqual([{ name: 'example.com' }]); + expect(readConfig().blocklist).toContainEqual({ name: 'example.com' }); }); test('Should not whitelist a distraction if shield mode is enabled', async () => { - enableShieldMode('ulysse'); + await enableShieldMode('ulysse'); - whitelistDistraction({ name: 'example.com' }); + await whitelistDistraction({ name: 'example.com' }); - expect(config.whitelist).toEqual([]); + expect(readConfig().whitelist).not.toContainEqual({ name: 'example.com' }); }); diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..10596d7 --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,11 @@ +require('dotenv/config'); +const fs = require('fs'); +const { CONFIG_PATH } = require('./src/constants'); + +module.exports = () => { + if (fs.existsSync(CONFIG_PATH)) { + fs.unlinkSync(CONFIG_PATH); + } + + import('./src/socket'); +}; diff --git a/package.json b/package.json index c7386c6..ee75718 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "ulysse", "author": "johackim", "description": "Simple CLI tool for blocking your distracting apps and websites", - "homepage": "https://github.com/johackim/ulysse", + "homepage": "https://github.com/getulysse/ulysse", "license": "GPL-3.0", "main": "dist/index.js", "version": "0.4.2", @@ -17,10 +17,10 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/johackim/ulysse.git" + "url": "git+https://github.com/getulysse/ulysse.git" }, "bugs": { - "url": "https://github.com/johackim/ulysse/issues" + "url": "https://github.com/getulysse/ulysse/issues" }, "keywords": [ "cli", @@ -59,9 +59,10 @@ "prebuild": "rm -rf dist", "build": "rollup --bundleConfigAsCjs -c", "start": "babel-node src/index.js", - "test": "DOTENV_CONFIG_PATH=.env.test jest --runInBand --setupFiles dotenv/config --forceExit" + "test": "DOTENV_CONFIG_PATH=.env.test jest -i --setupFiles dotenv/config --forceExit" }, "jest": { + "globalSetup": "./jest.setup.js", "restoreMocks": true, "transformIgnorePatterns": [], "transform": { diff --git a/src/block.js b/src/block.js index 11e4e91..8eb277e 100644 --- a/src/block.js +++ b/src/block.js @@ -4,7 +4,7 @@ import { isDistractionWhitelisted } from './whitelist'; import { DOMAIN_REGEX } from './constants'; import { removeDuplicates, getRootDomain, getTimeType, createTimeout } from './utils'; -export const blockDistraction = (distraction) => { +export const blockDistraction = async (distraction) => { config.blocklist = removeDuplicates([...config.blocklist, distraction]); config.blocklist = config.blocklist.map((d) => { if (getTimeType(d.time) === 'duration') { @@ -14,15 +14,15 @@ export const blockDistraction = (distraction) => { return d; }); - editConfig(config); + await editConfig(config); }; -export const unblockDistraction = (distraction) => { +export const unblockDistraction = async (distraction) => { if (config.shield) return; config.blocklist = config.blocklist.filter(({ name, time }) => JSON.stringify({ name, time }) !== JSON.stringify(distraction)); - editConfig(config); + await editConfig(config); }; export const isValidDomain = (domain) => DOMAIN_REGEX.test(domain); diff --git a/src/commands.js b/src/commands.js index 13776e6..e803dc2 100644 --- a/src/commands.js +++ b/src/commands.js @@ -1,4 +1,4 @@ -import path from 'path'; +import { isAbsolute } from 'path'; import { config } from './config'; import { getParam } from './utils'; import { version } from '../package.json'; @@ -60,7 +60,7 @@ export const whitelistCmd = (name) => { const password = getParam('--password') || getParam('-p'); const distraction = { name, time }; - if (!isValidDomain(name.replace('*.', '')) && !path.isAbsolute(name)) { + if (!isValidDomain(name.replace('*.', '')) && !isAbsolute(name)) { console.log('You must provide a valid distraction.'); return; } diff --git a/src/config.js b/src/config.js index b1f0b21..8892824 100644 --- a/src/config.js +++ b/src/config.js @@ -4,7 +4,7 @@ import { dirname } from 'path'; import { isSudo, tryCatch } from './utils'; import { CONFIG_PATH, DEFAULT_CONFIG, SOCKET_PATH } from './constants'; -export const sendDataToSocket = (data) => { +export const sendDataToSocket = (data) => new Promise((resolve, reject) => { const client = net.createConnection(SOCKET_PATH); if (typeof data === 'object') { @@ -14,23 +14,33 @@ export const sendDataToSocket = (data) => { } client.end(); -}; -export const config = (tryCatch(() => { + client.on('end', resolve); + + client.on('error', reject); +}); + +export const createConfig = () => { if (!fs.existsSync(CONFIG_PATH)) { fs.mkdirSync(dirname(CONFIG_PATH), { recursive: true }); fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 4), 'utf8'); } +}; +export const readConfig = () => { + createConfig(); return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); -}, DEFAULT_CONFIG))(); +}; -export const editConfig = (newConfig) => { +export const editConfig = async (newConfig) => { if (isSudo()) { fs.writeFileSync(CONFIG_PATH, JSON.stringify(newConfig, null, 4), 'utf8'); } else { - sendDataToSocket(newConfig); + await sendDataToSocket(newConfig); } - - return newConfig; }; + +export const config = (tryCatch(() => { + createConfig(); + return readConfig(); +}, DEFAULT_CONFIG))(); diff --git a/src/shield.js b/src/shield.js index a9e0f34..c978e2d 100644 --- a/src/shield.js +++ b/src/shield.js @@ -1,7 +1,8 @@ import fs from 'fs'; +import { isAbsolute } from 'path'; import { execSync } from 'child_process'; -import { config } from './config'; import { isValidApp } from './block'; +import { config, editConfig } from './config'; import { generatePassword, sha256 } from './utils'; export const isValidPassword = (password) => { @@ -10,29 +11,25 @@ export const isValidPassword = (password) => { return sha256sum === config.passwordHash; }; -export const enableShieldMode = (password = generatePassword()) => { +export const enableShieldMode = async (password = generatePassword()) => { const passwordHash = sha256(password); console.log(`Your password is: ${password}`); - config.password = password; - config.passwordHash = passwordHash; - config.shield = true; + await editConfig({ ...config, password, passwordHash, shield: true }); }; -export const disableShieldMode = (password) => { - if (isValidPassword(password)) { - config.shield = false; - delete config.passwordHash; - } +export const disableShieldMode = async (password) => { + await editConfig({ ...config, password, shield: false }); }; export const blockRoot = () => { if (process.env.NODE_ENV === 'test') return; + execSync('usermod -s /usr/sbin/nologin root'); fs.writeFileSync('/etc/sudoers.d/ulysse', `${process.env.SUDO_USER} ALL=(ALL) !ALL`, 'utf8'); for (const w of config.whitelist) { - if (isValidApp(w.name)) { + if (isValidApp(w.name) && isAbsolute(w.name)) { fs.appendFileSync('/etc/sudoers.d/ulysse', `\n${process.env.SUDO_USER} ALL=(ALL) ${w.name}`, 'utf8'); } } @@ -41,6 +38,8 @@ export const blockRoot = () => { }; export const unblockRoot = () => { + if (process.env.NODE_ENV === 'test') return; + execSync('usermod -s /bin/bash root'); if (fs.existsSync('/etc/sudoers.d/ulysse')) { fs.unlinkSync('/etc/sudoers.d/ulysse'); diff --git a/src/socket.io.js b/src/socket.io.js index 902eb0f..ac7fbc6 100644 --- a/src/socket.io.js +++ b/src/socket.io.js @@ -1,5 +1,5 @@ import { io } from 'socket.io-client'; -import { config, editConfig } from './config'; +import { readConfig, editConfig } from './config'; import { SERVER_HOST } from './constants'; const socket = io(SERVER_HOST); @@ -8,17 +8,17 @@ socket.on('connect', () => { console.log('Connected to the server'); }); -socket.on('synchronize', (newConfig) => { +socket.on('synchronize', async (newConfig) => { + const config = readConfig(); + if (new Date(newConfig.date) > new Date(config.date)) { - config.date = newConfig.date; - config.blocklist = newConfig.blocklist; - config.whitelist = newConfig.whitelist; - editConfig(config); + await editConfig({ ...newConfig, date: newConfig.date }); console.log('Synchronize...'); } }); setInterval(() => { + const config = readConfig(); socket.emit('synchronize', config); }, 60000); diff --git a/src/socket.js b/src/socket.js index 26900d1..e9f783a 100644 --- a/src/socket.js +++ b/src/socket.js @@ -1,11 +1,43 @@ +import 'dotenv/config'; import fs from 'fs'; import net from 'net'; import socket from './socket.io'; -import { config, editConfig } from './config'; -import { SOCKET_PATH } from './constants'; +import { config } from './config'; +import { removeDuplicates } from './utils'; +import { SOCKET_PATH, CONFIG_PATH } from './constants'; +import { blockRoot, unblockRoot, isValidPassword } from './shield'; if (fs.existsSync(SOCKET_PATH)) fs.unlinkSync(SOCKET_PATH); +const editConfig = (newConfig) => { + const { blocklist = [], whitelist = [], shield, password, passwordHash } = newConfig; + + config.date = new Date().toISOString(); + config.whitelist = removeDuplicates(config.shield ? config.whitelist : whitelist); + config.blocklist = removeDuplicates(config.shield ? [...config.blocklist, ...blocklist] : blocklist); + + if (isValidPassword(password)) { + unblockRoot(); + config.shield = false; + delete config.passwordHash; + } + + if (shield && passwordHash) { + blockRoot(); + config.shield = true; + config.passwordHash = passwordHash; + } + + delete config.password; + + socket.emit('synchronize', { + ...config, + date: new Date().toISOString(), + }); + + fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 4), 'utf8'); +}; + const server = net.createServer((connection) => { let buffer = ''; @@ -15,11 +47,7 @@ const server = net.createServer((connection) => { connection.on('end', () => { const newConfig = JSON.parse(buffer); - socket.emit('synchronize', { - ...editConfig(newConfig), - password: config?.password, - date: new Date().toISOString(), - }); + editConfig(newConfig); }); }); diff --git a/src/whitelist.js b/src/whitelist.js index e1b558e..62d04a5 100644 --- a/src/whitelist.js +++ b/src/whitelist.js @@ -1,9 +1,7 @@ -import fs from 'fs'; -import { config } from './config'; -import { CONFIG_PATH } from './constants'; +import { config, editConfig } from './config'; import { getRootDomain, removeDuplicates, getTimeType, createTimeout } from './utils'; -export const whitelistDistraction = (distraction) => { +export const whitelistDistraction = async (distraction) => { if (config.shield) return; config.whitelist = removeDuplicates([...config.whitelist, distraction]); @@ -15,7 +13,7 @@ export const whitelistDistraction = (distraction) => { return d; }); - fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 4), 'utf8'); + await editConfig(config); }; export const isDistractionWhitelisted = (distraction) => {