diff --git a/__tests__/utils.spec.js b/__tests__/utils.spec.js index b009475..2570912 100644 --- a/__tests__/utils.spec.js +++ b/__tests__/utils.spec.js @@ -8,6 +8,7 @@ import { getTimeType, createTimeout, getRunningApps, + isWithinTimeRange, isValidDistraction, isDistractionBlocked, } from '../src/utils'; @@ -142,6 +143,37 @@ test('Should get duration time type', () => { expect(getTimeType('10h-18h')).toBe('interval'); }); +test('Should block an app', async () => { + const config = { blocklist: [{ name: 'chromium' }], whitelist: [] }; + jest.spyOn(fs, 'readFileSync').mockImplementation(() => JSON.stringify(config)); + + const isBlocked = isDistractionBlocked('chromium'); + + expect(isBlocked).toBe(true); +}); + +test('Should block an app with a time-based interval', async () => { + const currentDate = new Date('2021-01-01T12:00:00Z'); + const config = { blocklist: [{ name: 'chromium', time: '0h-23h' }], whitelist: [] }; + jest.spyOn(fs, 'readFileSync').mockImplementation(() => JSON.stringify(config)); + jest.spyOn(global, 'Date').mockImplementation(() => currentDate); + + const isBlocked = isDistractionBlocked('chromium'); + + expect(isBlocked).toBe(true); +}); + +test('Should not block an app with a time-based interval', async () => { + const currentDate = new Date('2021-01-01T23:30:00Z'); + const config = { blocklist: [{ name: 'chromium', time: '0h-23h' }], whitelist: [] }; + jest.spyOn(fs, 'readFileSync').mockImplementation(() => JSON.stringify(config)); + jest.spyOn(global, 'Date').mockImplementation(() => currentDate); + + const isBlocked = isDistractionBlocked('chromium'); + + expect(isBlocked).toBe(false); +}); + test('Should block a specific subdomain', async () => { const config = { blocklist: [{ name: 'www.example.com' }], whitelist: [] }; jest.spyOn(fs, 'readFileSync').mockImplementation(() => JSON.stringify(config)); @@ -239,3 +271,62 @@ test('Should remove a distraction from blocklist if timeout is reached and shiel expect(readConfig(TEST_CONFIG_PATH).blocklist).toEqual([{ name: 'chromium' }]); }); + +test('Should block domains from an external blocklist', async () => { + jest.spyOn(fs, 'existsSync').mockImplementation(() => true); + jest.spyOn(fs, 'statSync').mockImplementation(() => ({ mtimeMs: 0 })); + jest.spyOn(fs, 'readFileSync').mockImplementation((path) => { + if (path === '/etc/ulysse/config.json') { + return JSON.stringify({ blocklist: [], whitelist: [] }); + } + + if (path === '/etc/ulysse/blocklist.json') { + return JSON.stringify([{ name: 'facebook.com' }, { name: 'twitter.com' }]); + } + + return ''; + }); + + const isBlocked = isDistractionBlocked('twitter.com'); + + expect(isBlocked).toBe(true); +}); + +test('Should run isDistractionBlocked in less than 100ms with a large external blocklist', async () => { + jest.spyOn(fs, 'statSync').mockImplementation(() => ({ mtimeMs: 1 })); + jest.spyOn(fs, 'readFileSync').mockImplementation((path) => { + if (path === '/etc/ulysse/config.json') { + return JSON.stringify({ blocklist: [], whitelist: [] }); + } + + if (path === '/etc/ulysse/blocklist.json') { + return JSON.stringify(Array.from({ length: 500000 }, (_, i) => ({ name: `${i + 1}.com` }))); + } + + return ''; + }); + + isDistractionBlocked('example.com'); + + const start = process.hrtime(); + isDistractionBlocked('example.com'); + const end = process.hrtime(start); + + expect(end[1] / 1000000).toBeLessThan(100); +}); + +test('Should check if a time is within an interval', async () => { + const currentDate = new Date('2021-01-01T12:00:00Z'); + jest.spyOn(global, 'Date').mockImplementation(() => currentDate); + + expect(isWithinTimeRange('0h-23h')).toBe(true); + expect(isWithinTimeRange('0h-19h')).toBe(true); + expect(isWithinTimeRange('20h-23h')).toBe(false); +}); + +test('Should check if a time is within an interval', async () => { + const currentDate = new Date('2021-01-01T23:30:00Z'); + jest.spyOn(global, 'Date').mockImplementation(() => currentDate); + + expect(isWithinTimeRange('0h-23h')).toBe(false); +}); diff --git a/src/utils.js b/src/utils.js index e1f34c2..55b3571 100644 --- a/src/utils.js +++ b/src/utils.js @@ -12,6 +12,8 @@ import { SOCKET_PATH, } from './constants'; +const cache = {}; + const tryCatch = (fn, fallback = false, retry = 0) => (...args) => { try { return fn(...args); @@ -221,37 +223,53 @@ export const whitelistDistraction = (distraction) => { sendDataToSocket(config); }; -export const rootDomain = (domain) => domain.split('.').slice(-2).join('.'); +export const getRootDomain = (domain) => domain.split('.').slice(-2).join('.'); -/* eslint-disable-next-line complexity */ -export const isDistractionBlocked = (distraction) => { - const { blocklist, whitelist } = readConfig(); - const time = blocklist.find((d) => d.name === distraction || d.name === `*.${rootDomain(distraction)}`)?.time; - - if (whitelist.some((d) => d.name === distraction)) return false; - if (whitelist.some((d) => d.name === `*.${rootDomain(distraction)}`)) return false; +export const isDistractionWhitelisted = (distraction) => { + const { whitelist } = readConfig(); - if (isValidDomain(distraction) && blocklist.some((d) => d.name === '*.*')) return true; + if (whitelist.some((d) => d.name === distraction)) return true; + if (whitelist.some((d) => d.name === `*.${getRootDomain(distraction)}`)) return true; - if (getTimeType(time) === 'interval') { - const date = new Date(); - const hour = date.getHours(); + return false; +}; - const [start, end] = time.split('-').map((t) => parseInt(t, 10)); +export const appendExternalBlocklist = (blocklist = [], path = '/etc/ulysse/blocklist.json') => { + if (!fs.existsSync(path)) return blocklist; + const date = fs.statSync(path).mtimeMs; - return hour >= start && hour < end; + if (date !== cache[path]?.date) { + const externalBlocklist = JSON.parse(fs.readFileSync(path, 'utf8')); + const combinedBlocklist = new Set([...blocklist, ...externalBlocklist]); + cache[path] = { blocklist: Array.from(combinedBlocklist), date }; } - const isBlocked = blocklist.some(({ name }) => { - if (name.includes('*')) { - const [, domain] = name.split('*.'); - return domain === rootDomain(distraction); - } + return cache[path]?.blocklist; +}; - return name === distraction || name === rootDomain(distraction); - }); +export const isWithinTimeRange = (time) => { + if (!time) return true; + if (getTimeType(time) !== 'interval') return false; + + const [start, end] = time.split('-').map((t) => parseInt(t, 10)); + const hour = new Date().getUTCHours(); + + return hour >= start && hour < end; +}; + +export const isDomainBlocked = (domain, rule, rootDomain) => { + if (!isValidDomain(domain)) return false; + return rule === '*.*' || rule === domain || rule === `*.${rootDomain}`; +}; + +export const isDistractionBlocked = (distraction) => { + if (isDistractionWhitelisted(distraction)) return false; + + const config = readConfig(); + const rootDomain = getRootDomain(distraction); + const blocklist = appendExternalBlocklist(config.blocklist); - return isBlocked; + return blocklist.some(({ name, time }) => (name === distraction || isDomainBlocked(distraction, name, rootDomain)) && isWithinTimeRange(time)); }; export const isSudo = () => !!process.env.SUDO_USER; @@ -270,9 +288,9 @@ export const blockApps = () => { const config = readConfig(); const blocklist = config.blocklist - .filter((d) => isValidApp(d.name)) - .filter((d) => isDistractionBlocked(d.name)) - .map(({ name }) => name); + .map(({ name }) => name) + .filter((name) => isValidApp(name)) + .filter((name) => isDistractionBlocked(name)); const blockedApps = getRunningApps() .filter((a) => blocklist?.includes(a.cmd) || blocklist?.includes(a.bin) || blocklist?.includes(a.name))