From a582e1f272dd9524b17a6b6dbc95d77bb78f7187 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 31 Jan 2026 04:07:32 +0000 Subject: [PATCH] first commit - skeletal frame --- .env.example | 4 + .gitignore | 8 + backend/Dockerfile | 7 + backend/package.json | 17 ++ backend/src/index.js | 51 +++++ backend/src/routes/curfew.js | 34 ++++ backend/src/routes/devices.js | 35 ++++ backend/src/routes/policies.js | 63 ++++++ backend/src/routes/rules.js | 16 ++ backend/src/routes/timers.js | 36 ++++ backend/src/services/curfewService.js | 124 ++++++++++++ backend/src/services/policyEngine.js | 109 +++++++++++ backend/src/services/timerService.js | 116 +++++++++++ backend/src/services/unifiClient.js | 123 ++++++++++++ docker-compose.yml | 18 ++ frontend/Dockerfile | 7 + frontend/index.html | 16 ++ frontend/package.json | 21 ++ frontend/public/manifest.json | 10 + frontend/src/App.jsx | 20 ++ frontend/src/components/BonusTimeDrawer.jsx | 131 +++++++++++++ frontend/src/components/BottomNav.jsx | 63 ++++++ frontend/src/components/CategoryCard.jsx | 103 ++++++++++ frontend/src/components/CurfewClock.jsx | 188 ++++++++++++++++++ frontend/src/components/DeviceCard.jsx | 88 +++++++++ frontend/src/components/RuleAssigner.jsx | 140 +++++++++++++ frontend/src/hooks/useApi.js | 40 ++++ frontend/src/index.css | 205 ++++++++++++++++++++ frontend/src/main.jsx | 10 + frontend/src/pages/ControlDashboard.jsx | 103 ++++++++++ frontend/src/pages/DeviceLibrary.jsx | 121 ++++++++++++ frontend/vite.config.js | 16 ++ 32 files changed, 2043 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/index.js create mode 100644 backend/src/routes/curfew.js create mode 100644 backend/src/routes/devices.js create mode 100644 backend/src/routes/policies.js create mode 100644 backend/src/routes/rules.js create mode 100644 backend/src/routes/timers.js create mode 100644 backend/src/services/curfewService.js create mode 100644 backend/src/services/policyEngine.js create mode 100644 backend/src/services/timerService.js create mode 100644 backend/src/services/unifiClient.js create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/public/manifest.json create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/components/BonusTimeDrawer.jsx create mode 100644 frontend/src/components/BottomNav.jsx create mode 100644 frontend/src/components/CategoryCard.jsx create mode 100644 frontend/src/components/CurfewClock.jsx create mode 100644 frontend/src/components/DeviceCard.jsx create mode 100644 frontend/src/components/RuleAssigner.jsx create mode 100644 frontend/src/hooks/useApi.js create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/ControlDashboard.jsx create mode 100644 frontend/src/pages/DeviceLibrary.jsx create mode 100644 frontend/vite.config.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..294035b --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +UDR_IP=192.168.1.1 +UDR_USER=admin +UDR_PASSWORD=changeme +APP_SECRET_KEY=changeme diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cacad58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.env +data/*.json +*.log +.DS_Store +CLAUDE.md +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..fc723f3 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev +COPY src/ ./src/ +EXPOSE 3001 +CMD ["node", "src/index.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..c7cf215 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,17 @@ +{ + "name": "barkwho-backend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js" + }, + "dependencies": { + "express": "^4.18.2", + "axios": "^1.6.7", + "node-cron": "^3.0.3", + "cors": "^2.8.5", + "uuid": "^9.0.0" + } +} diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..92a23e2 --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,51 @@ +import express from 'express'; +import cors from 'cors'; +import { login } from './services/unifiClient.js'; +import { loadPolicies } from './services/policyEngine.js'; +import { loadTimers } from './services/timerService.js'; +import { loadCurfew } from './services/curfewService.js'; +import policiesRouter from './routes/policies.js'; +import devicesRouter from './routes/devices.js'; +import timersRouter from './routes/timers.js'; +import curfewRouter from './routes/curfew.js'; +import rulesRouter from './routes/rules.js'; + +const app = express(); +const PORT = 3001; + +app.use(cors()); +app.use(express.json()); + +app.use('/api/policies', policiesRouter); +app.use('/api/devices', devicesRouter); +app.use('/api/timers', timersRouter); +app.use('/api/curfew', curfewRouter); +app.use('/api/rules', rulesRouter); + +app.get('/api/health', (req, res) => { + res.json({ status: 'ok', timestamp: Date.now() }); +}); + +async function startup() { + console.log('[BarkWho] Starting backend...'); + + // Load persistent state first + loadPolicies(); + loadTimers(); + loadCurfew(); + + // Attempt UDR authentication (non-blocking if no creds) + const authed = await login(); + if (!authed) { + console.warn('[BarkWho] Running without UDR connection - configure .env to connect'); + } + + app.listen(PORT, '0.0.0.0', () => { + console.log(`[BarkWho] Backend listening on port ${PORT}`); + }); +} + +startup().catch((err) => { + console.error('[BarkWho] Startup failed:', err); + process.exit(1); +}); diff --git a/backend/src/routes/curfew.js b/backend/src/routes/curfew.js new file mode 100644 index 0000000..5153c4b --- /dev/null +++ b/backend/src/routes/curfew.js @@ -0,0 +1,34 @@ +import { Router } from 'express'; +import { getCurfew, updateCurfew, overrideCurfew } from '../services/curfewService.js'; + +const router = Router(); + +router.get('/', (req, res) => { + try { + res.json(getCurfew()); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.put('/', (req, res) => { + try { + const result = updateCurfew(req.body); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/override', (req, res) => { + try { + const { minutes } = req.body; + if (!minutes) return res.status(400).json({ error: '"minutes" is required' }); + const result = overrideCurfew(Number(minutes)); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +export default router; diff --git a/backend/src/routes/devices.js b/backend/src/routes/devices.js new file mode 100644 index 0000000..bb4d372 --- /dev/null +++ b/backend/src/routes/devices.js @@ -0,0 +1,35 @@ +import { Router } from 'express'; +import { getClients, blockClient, unblockClient } from '../services/unifiClient.js'; + +const router = Router(); + +router.get('/', async (req, res) => { + try { + const clients = await getClients(); + res.json(clients); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/:mac/nuke', async (req, res) => { + try { + const { mac } = req.params; + const result = await blockClient(decodeURIComponent(mac)); + res.json({ mac, blocked: true, result }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/:mac/unnuke', async (req, res) => { + try { + const { mac } = req.params; + const result = await unblockClient(decodeURIComponent(mac)); + res.json({ mac, blocked: false, result }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +export default router; diff --git a/backend/src/routes/policies.js b/backend/src/routes/policies.js new file mode 100644 index 0000000..856e8c5 --- /dev/null +++ b/backend/src/routes/policies.js @@ -0,0 +1,63 @@ +import { Router } from 'express'; +import { getCategories, toggleCategory, assignRules, addCategory, deleteCategory } from '../services/policyEngine.js'; + +const router = Router(); + +router.get('/', async (req, res) => { + try { + const categories = await getCategories(); + res.json(categories); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/:category/toggle', async (req, res) => { + try { + const { category } = req.params; + const { allowed } = req.body; + if (typeof allowed !== 'boolean') { + return res.status(400).json({ error: '"allowed" must be a boolean' }); + } + const results = await toggleCategory(decodeURIComponent(category), allowed); + res.json({ category, allowed, results }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.put('/:category/rules', (req, res) => { + try { + const { category } = req.params; + const { ruleIds } = req.body; + if (!Array.isArray(ruleIds)) { + return res.status(400).json({ error: '"ruleIds" must be an array' }); + } + const result = assignRules(decodeURIComponent(category), ruleIds); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/', (req, res) => { + try { + const { name, icon } = req.body; + if (!name) return res.status(400).json({ error: '"name" is required' }); + const result = addCategory(name, icon); + res.json(result); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +router.delete('/:category', (req, res) => { + try { + deleteCategory(decodeURIComponent(req.params.category)); + res.json({ success: true }); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +export default router; diff --git a/backend/src/routes/rules.js b/backend/src/routes/rules.js new file mode 100644 index 0000000..d5cfb80 --- /dev/null +++ b/backend/src/routes/rules.js @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { getTrafficRules } from '../services/unifiClient.js'; + +const router = Router(); + +router.get('/', async (req, res) => { + try { + const rules = await getTrafficRules(); + const list = Array.isArray(rules) ? rules : rules?.data || []; + res.json(list); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +export default router; diff --git a/backend/src/routes/timers.js b/backend/src/routes/timers.js new file mode 100644 index 0000000..668aca3 --- /dev/null +++ b/backend/src/routes/timers.js @@ -0,0 +1,36 @@ +import { Router } from 'express'; +import { getTimers, createTimer, cancelTimer } from '../services/timerService.js'; + +const router = Router(); + +router.get('/', (req, res) => { + try { + res.json(getTimers()); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/', async (req, res) => { + try { + const { category, minutes } = req.body; + if (!category || !minutes) { + return res.status(400).json({ error: '"category" and "minutes" are required' }); + } + const timer = await createTimer(category, Number(minutes)); + res.json(timer); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.delete('/:id', (req, res) => { + try { + const timer = cancelTimer(req.params.id); + res.json({ cancelled: true, timer }); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +export default router; diff --git a/backend/src/services/curfewService.js b/backend/src/services/curfewService.js new file mode 100644 index 0000000..bbb66e1 --- /dev/null +++ b/backend/src/services/curfewService.js @@ -0,0 +1,124 @@ +import fs from 'fs'; +import path from 'path'; +import cron from 'node-cron'; +import { toggleCategory } from './policyEngine.js'; + +const DATA_PATH = '/data/curfew.json'; + +const DEFAULT_CURFEW = { + enabled: true, + blockTime: '21:00', + unblockTime: '07:00', + categories: ['Social Media', 'Streaming', 'Gaming'], + overrideUntil: null, +}; + +let curfewConfig = {}; +let blockJob = null; +let unblockJob = null; + +export function loadCurfew() { + try { + if (fs.existsSync(DATA_PATH)) { + const raw = fs.readFileSync(DATA_PATH, 'utf-8'); + curfewConfig = JSON.parse(raw); + console.log('[Curfew] Loaded curfew config from disk'); + } else { + curfewConfig = { ...DEFAULT_CURFEW }; + saveCurfew(); + console.log('[Curfew] Created default curfew config'); + } + } catch (err) { + console.error('[Curfew] Error loading curfew:', err.message); + curfewConfig = { ...DEFAULT_CURFEW }; + } + scheduleCurfew(); +} + +function saveCurfew() { + const dir = path.dirname(DATA_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(DATA_PATH, JSON.stringify(curfewConfig, null, 2)); +} + +function timeToCron(timeStr) { + const [hours, minutes] = timeStr.split(':').map(Number); + return `${minutes} ${hours} * * *`; +} + +function scheduleCurfew() { + if (blockJob) { blockJob.stop(); blockJob = null; } + if (unblockJob) { unblockJob.stop(); unblockJob = null; } + + if (!curfewConfig.enabled) { + console.log('[Curfew] Curfew is disabled'); + return; + } + + const blockCron = timeToCron(curfewConfig.blockTime); + const unblockCron = timeToCron(curfewConfig.unblockTime); + + blockJob = cron.schedule(blockCron, async () => { + if (isOverrideActive()) { + console.log('[Curfew] Override active, skipping block'); + return; + } + console.log('[Curfew] Bedtime! Blocking categories...'); + for (const cat of curfewConfig.categories) { + try { + await toggleCategory(cat, false); + } catch (err) { + console.error(`[Curfew] Failed to block ${cat}:`, err.message); + } + } + }); + + unblockJob = cron.schedule(unblockCron, async () => { + console.log('[Curfew] Good morning! Unblocking categories...'); + curfewConfig.overrideUntil = null; + saveCurfew(); + for (const cat of curfewConfig.categories) { + try { + await toggleCategory(cat, true); + } catch (err) { + console.error(`[Curfew] Failed to unblock ${cat}:`, err.message); + } + } + }); + + console.log(`[Curfew] Scheduled: block at ${curfewConfig.blockTime}, unblock at ${curfewConfig.unblockTime}`); +} + +function isOverrideActive() { + if (!curfewConfig.overrideUntil) return false; + if (Date.now() < curfewConfig.overrideUntil) return true; + curfewConfig.overrideUntil = null; + saveCurfew(); + return false; +} + +export function getCurfew() { + return { + ...curfewConfig, + overrideActive: isOverrideActive(), + }; +} + +export function updateCurfew(updates) { + Object.assign(curfewConfig, updates); + saveCurfew(); + scheduleCurfew(); + return getCurfew(); +} + +export function overrideCurfew(minutes) { + curfewConfig.overrideUntil = Date.now() + minutes * 60 * 1000; + saveCurfew(); + // Unblock categories during override + for (const cat of curfewConfig.categories) { + toggleCategory(cat, true).catch((err) => + console.error(`[Curfew] Override unblock failed for ${cat}:`, err.message) + ); + } + return getCurfew(); +} diff --git a/backend/src/services/policyEngine.js b/backend/src/services/policyEngine.js new file mode 100644 index 0000000..7f97633 --- /dev/null +++ b/backend/src/services/policyEngine.js @@ -0,0 +1,109 @@ +import fs from 'fs'; +import path from 'path'; +import { getTrafficRules, getTrafficRule, updateTrafficRule } from './unifiClient.js'; + +const DATA_PATH = '/data/policy_map.json'; + +const DEFAULT_CATEGORIES = { + 'Social Media': { ruleIds: [], icon: 'social' }, + 'Streaming': { ruleIds: [], icon: 'streaming' }, + 'Adult Content': { ruleIds: [], icon: 'adult' }, + 'Gaming': { ruleIds: [], icon: 'gaming' }, +}; + +let policyMap = {}; + +export function loadPolicies() { + try { + if (fs.existsSync(DATA_PATH)) { + const raw = fs.readFileSync(DATA_PATH, 'utf-8'); + policyMap = JSON.parse(raw); + console.log('[PolicyEngine] Loaded policy map from disk'); + } else { + policyMap = structuredClone(DEFAULT_CATEGORIES); + savePolicies(); + console.log('[PolicyEngine] Created default policy map'); + } + } catch (err) { + console.error('[PolicyEngine] Error loading policies:', err.message); + policyMap = structuredClone(DEFAULT_CATEGORIES); + } +} + +function savePolicies() { + const dir = path.dirname(DATA_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(DATA_PATH, JSON.stringify(policyMap, null, 2)); +} + +export async function getCategories() { + const allRules = await getTrafficRules(); + const ruleList = Array.isArray(allRules) ? allRules : allRules?.data || []; + const ruleMap = new Map(ruleList.map((r) => [r._id, r])); + + const categories = {}; + for (const [name, config] of Object.entries(policyMap)) { + const rules = config.ruleIds.map((id) => { + const rule = ruleMap.get(id); + return { + id, + name: rule?.description || rule?.name || id, + enabled: rule?.enabled ?? false, + }; + }); + // Category is "allowed" when ALL its rules are disabled (not blocking) + const allDisabled = rules.length === 0 || rules.every((r) => !r.enabled); + categories[name] = { + icon: config.icon, + ruleIds: config.ruleIds, + rules, + allowed: allDisabled, + }; + } + return categories; +} + +export async function toggleCategory(categoryName, allowed) { + const config = policyMap[categoryName]; + if (!config) throw new Error(`Category "${categoryName}" not found`); + + const results = []; + for (const ruleId of config.ruleIds) { + const rule = await getTrafficRule(ruleId); + if (!rule) { + results.push({ ruleId, success: false, error: 'Rule not found' }); + continue; + } + // Inversion: UI "allowed" = rule disabled, UI "blocked" = rule enabled + rule.enabled = !allowed; + try { + await updateTrafficRule(ruleId, rule); + results.push({ ruleId, success: true }); + } catch (err) { + results.push({ ruleId, success: false, error: err.message }); + } + } + return results; +} + +export function assignRules(categoryName, ruleIds) { + if (!policyMap[categoryName]) { + policyMap[categoryName] = { ruleIds: [], icon: 'custom' }; + } + policyMap[categoryName].ruleIds = ruleIds; + savePolicies(); + return policyMap[categoryName]; +} + +export function addCategory(name, icon = 'custom') { + if (policyMap[name]) throw new Error(`Category "${name}" already exists`); + policyMap[name] = { ruleIds: [], icon }; + savePolicies(); + return policyMap[name]; +} + +export function deleteCategory(name) { + if (!policyMap[name]) throw new Error(`Category "${name}" not found`); + delete policyMap[name]; + savePolicies(); +} diff --git a/backend/src/services/timerService.js b/backend/src/services/timerService.js new file mode 100644 index 0000000..243b549 --- /dev/null +++ b/backend/src/services/timerService.js @@ -0,0 +1,116 @@ +import fs from 'fs'; +import path from 'path'; +import { v4 as uuidv4 } from 'uuid'; +import { toggleCategory } from './policyEngine.js'; + +const DATA_PATH = '/data/timers.json'; + +let activeTimers = []; +let timeoutHandles = new Map(); + +export function loadTimers() { + try { + if (fs.existsSync(DATA_PATH)) { + const raw = fs.readFileSync(DATA_PATH, 'utf-8'); + activeTimers = JSON.parse(raw); + console.log(`[TimerService] Loaded ${activeTimers.length} timers from disk`); + resumeTimers(); + } else { + activeTimers = []; + saveTimers(); + } + } catch (err) { + console.error('[TimerService] Error loading timers:', err.message); + activeTimers = []; + } +} + +function saveTimers() { + const dir = path.dirname(DATA_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(DATA_PATH, JSON.stringify(activeTimers, null, 2)); +} + +function resumeTimers() { + const now = Date.now(); + const expired = []; + const valid = []; + + for (const timer of activeTimers) { + const remaining = timer.expiresAt - now; + if (remaining <= 0) { + expired.push(timer); + } else { + valid.push(timer); + scheduleTimeout(timer, remaining); + } + } + + // Fire expired timers immediately + for (const timer of expired) { + console.log(`[TimerService] Timer ${timer.id} expired while offline, re-blocking ${timer.category}`); + onTimerExpire(timer); + } +} + +function scheduleTimeout(timer, ms) { + const handle = setTimeout(() => onTimerExpire(timer), ms); + timeoutHandles.set(timer.id, handle); +} + +async function onTimerExpire(timer) { + console.log(`[TimerService] Timer expired: ${timer.id} for ${timer.category}`); + try { + await toggleCategory(timer.category, false); // Re-block + } catch (err) { + console.error(`[TimerService] Failed to re-block ${timer.category}:`, err.message); + } + activeTimers = activeTimers.filter((t) => t.id !== timer.id); + timeoutHandles.delete(timer.id); + saveTimers(); +} + +export async function createTimer(category, minutes) { + const now = Date.now(); + const expiresAt = now + minutes * 60 * 1000; + const timer = { + id: uuidv4(), + category, + minutes, + createdAt: now, + expiresAt, + }; + + // Allow the category during bonus time + await toggleCategory(category, true); + + activeTimers.push(timer); + saveTimers(); + scheduleTimeout(timer, minutes * 60 * 1000); + + console.log(`[TimerService] Created timer ${timer.id}: ${category} for ${minutes}m`); + return timer; +} + +export function getTimers() { + const now = Date.now(); + return activeTimers.map((t) => ({ + ...t, + remainingMs: Math.max(0, t.expiresAt - now), + })); +} + +export function cancelTimer(id) { + const timer = activeTimers.find((t) => t.id === id); + if (!timer) throw new Error('Timer not found'); + + const handle = timeoutHandles.get(id); + if (handle) clearTimeout(handle); + timeoutHandles.delete(id); + + activeTimers = activeTimers.filter((t) => t.id !== id); + saveTimers(); + + console.log(`[TimerService] Cancelled timer ${id}`); + return timer; +} diff --git a/backend/src/services/unifiClient.js b/backend/src/services/unifiClient.js new file mode 100644 index 0000000..8bd3183 --- /dev/null +++ b/backend/src/services/unifiClient.js @@ -0,0 +1,123 @@ +import axios from 'axios'; +import https from 'https'; + +const agent = new https.Agent({ rejectUnauthorized: false }); + +let baseURL = ''; +let cookies = ''; +let csrfToken = ''; + +function getEnvConfig() { + const ip = process.env.UDR_IP; + const user = process.env.UDR_USER; + const pass = process.env.UDR_PASSWORD; + if (!ip || !user || !pass) { + return null; + } + return { ip, user, pass }; +} + +const client = axios.create({ httpsAgent: agent, timeout: 10000 }); + +client.interceptors.response.use( + (res) => res, + async (error) => { + const original = error.config; + if (error.response?.status === 401 && !original._retry) { + original._retry = true; + await login(); + original.headers['Cookie'] = cookies; + original.headers['x-csrf-token'] = csrfToken; + return client(original); + } + return Promise.reject(error); + } +); + +export async function login() { + const config = getEnvConfig(); + if (!config) { + console.warn('[UniFi] No UDR credentials configured - running in demo mode'); + return false; + } + baseURL = `https://${config.ip}`; + try { + const res = await axios.post( + `${baseURL}/api/auth/login`, + { username: config.user, password: config.pass }, + { httpsAgent: agent, timeout: 10000 } + ); + const setCookie = res.headers['set-cookie']; + if (setCookie) { + cookies = setCookie.map((c) => c.split(';')[0]).join('; '); + } + csrfToken = res.headers['x-csrf-token'] || ''; + console.log('[UniFi] Authenticated successfully'); + return true; + } catch (err) { + console.error('[UniFi] Login failed:', err.message); + return false; + } +} + +function authHeaders() { + return { + Cookie: cookies, + 'x-csrf-token': csrfToken, + }; +} + +export async function getTrafficRules() { + if (!baseURL) return []; + const res = await client.get( + `${baseURL}/proxy/network/v2/api/site/default/trafficrules`, + { headers: authHeaders() } + ); + return res.data; +} + +export async function getTrafficRule(ruleId) { + if (!baseURL) return null; + const rules = await getTrafficRules(); + const list = Array.isArray(rules) ? rules : rules?.data || []; + return list.find((r) => r._id === ruleId) || null; +} + +export async function updateTrafficRule(ruleId, rulePayload) { + if (!baseURL) return null; + const res = await client.put( + `${baseURL}/proxy/network/v2/api/site/default/trafficrules/${ruleId}`, + rulePayload, + { headers: { ...authHeaders(), 'Content-Type': 'application/json' } } + ); + return res.data; +} + +export async function getClients() { + if (!baseURL) return []; + const res = await client.get( + `${baseURL}/proxy/network/api/s/default/stat/sta`, + { headers: authHeaders() } + ); + return res.data?.data || res.data || []; +} + +export async function blockClient(mac) { + if (!baseURL) return null; + const res = await client.post( + `${baseURL}/proxy/network/api/s/default/cmd/stamgr`, + { cmd: 'block-sta', mac: mac.toLowerCase() }, + { headers: { ...authHeaders(), 'Content-Type': 'application/json' } } + ); + return res.data; +} + +export async function unblockClient(mac) { + if (!baseURL) return null; + const res = await client.post( + `${baseURL}/proxy/network/api/s/default/cmd/stamgr`, + { cmd: 'unblock-sta', mac: mac.toLowerCase() }, + { headers: { ...authHeaders(), 'Content-Type': 'application/json' } } + ); + return res.data; +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1c4855d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + backend: + build: ./backend + ports: + - "3001:3001" + volumes: + - ./data:/data + env_file: + - .env + restart: unless-stopped + + frontend: + build: ./frontend + ports: + - "5173:5173" + depends_on: + - backend + restart: unless-stopped diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..342df35 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +EXPOSE 5173 +CMD ["npm", "run", "dev"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4e11756 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + BarkWho + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..00bbf52 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "barkwho-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "framer-motion": "^10.16.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.0", + "vite": "^5.0.0" + } +} diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..c78c0ec --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,10 @@ +{ + "name": "BarkWho", + "short_name": "BarkWho", + "description": "Parental Control Dashboard", + "start_url": "/", + "display": "standalone", + "background_color": "#2E473B", + "theme_color": "#2E473B", + "icons": [] +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..bae65d1 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,20 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import BottomNav from './components/BottomNav.jsx'; +import ControlDashboard from './pages/ControlDashboard.jsx'; +import DeviceLibrary from './pages/DeviceLibrary.jsx'; + +export default function App() { + return ( + +
+
+ + } /> + } /> + +
+ +
+
+ ); +} diff --git a/frontend/src/components/BonusTimeDrawer.jsx b/frontend/src/components/BonusTimeDrawer.jsx new file mode 100644 index 0000000..5721504 --- /dev/null +++ b/frontend/src/components/BonusTimeDrawer.jsx @@ -0,0 +1,131 @@ +import { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; + +const PRESETS = [ + { label: '15m', minutes: 15 }, + { label: '30m', minutes: 30 }, + { label: '60m', minutes: 60 }, +]; + +export default function BonusTimeDrawer({ category, onSubmit, onClose }) { + const [custom, setCustom] = useState(''); + + const handleCustomSubmit = () => { + const mins = parseInt(custom, 10); + if (mins > 0) onSubmit(mins); + }; + + return ( + +
+ e.stopPropagation()} + > +
+

Bonus Time: {category}

+ +
+ {PRESETS.map((p) => ( + + ))} +
+ +
+ setCustom(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCustomSubmit()} + /> + +
+ + + +
+ + + + ); +} diff --git a/frontend/src/components/BottomNav.jsx b/frontend/src/components/BottomNav.jsx new file mode 100644 index 0000000..64e34be --- /dev/null +++ b/frontend/src/components/BottomNav.jsx @@ -0,0 +1,63 @@ +import { useLocation, useNavigate } from 'react-router-dom'; + +const tabs = [ + { path: '/', label: 'Control', icon: '[ ]' }, + { path: '/devices', label: 'Devices', icon: '{*}' }, +]; + +export default function BottomNav() { + const location = useLocation(); + const navigate = useNavigate(); + + return ( + + ); +} diff --git a/frontend/src/components/CategoryCard.jsx b/frontend/src/components/CategoryCard.jsx new file mode 100644 index 0000000..201df3c --- /dev/null +++ b/frontend/src/components/CategoryCard.jsx @@ -0,0 +1,103 @@ +export default function CategoryCard({ name, config, timers, onToggle, onBonusTime, onAssignRules }) { + const { allowed, rules } = config; + const activeTimer = timers?.[0]; + + const iconMap = { + social: '{ }', + streaming: '> |', + adult: '[X]', + gaming: '', + custom: '...', + }; + + const formatRemaining = (ms) => { + const mins = Math.ceil(ms / 60000); + if (mins >= 60) return `${Math.floor(mins / 60)}h ${mins % 60}m`; + return `${mins}m`; + }; + + return ( +
+
+
+ {iconMap[config.icon] || iconMap.custom} +
+

{name}

+ + {rules.length} rule{rules.length !== 1 ? 's' : ''} + {activeTimer && ( + + {' '}| Bonus: {formatRemaining(activeTimer.remainingMs)} + + )} + +
+
+ +
+ +
+ + +
+ + +
+ ); +} diff --git a/frontend/src/components/CurfewClock.jsx b/frontend/src/components/CurfewClock.jsx new file mode 100644 index 0000000..a9fb809 --- /dev/null +++ b/frontend/src/components/CurfewClock.jsx @@ -0,0 +1,188 @@ +import { useState, useEffect } from 'react'; +import { apiFetch } from '../hooks/useApi.js'; + +export default function CurfewClock({ curfew, timers, onCancelTimer, refetchCurfew }) { + const [now, setNow] = useState(Date.now()); + + useEffect(() => { + const interval = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(interval); + }, []); + + if (!curfew) return null; + + const getNextEvent = () => { + const today = new Date(); + const [blockH, blockM] = curfew.blockTime.split(':').map(Number); + const [unblockH, unblockM] = curfew.unblockTime.split(':').map(Number); + + const blockToday = new Date(today); + blockToday.setHours(blockH, blockM, 0, 0); + + const unblockTomorrow = new Date(today); + unblockTomorrow.setDate(unblockTomorrow.getDate() + 1); + unblockTomorrow.setHours(unblockH, unblockM, 0, 0); + + const unblockToday = new Date(today); + unblockToday.setHours(unblockH, unblockM, 0, 0); + + if (now < unblockToday.getTime()) { + return { label: 'Curfew ends in', target: unblockToday.getTime() }; + } else if (now < blockToday.getTime()) { + return { label: 'Curfew starts in', target: blockToday.getTime() }; + } else { + return { label: 'Curfew ends in', target: unblockTomorrow.getTime() }; + } + }; + + const formatCountdown = (ms) => { + const totalSec = Math.max(0, Math.floor(ms / 1000)); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + }; + + const handleOverride = async (minutes) => { + await apiFetch('/curfew/override', { + method: 'POST', + body: JSON.stringify({ minutes }), + }); + refetchCurfew(); + }; + + const event = curfew.enabled ? getNextEvent() : null; + const activeTimers = (timers || []).filter((t) => t.remainingMs > 0); + + return ( +
+
+ CURFEW + + {curfew.enabled ? 'ARMED' : 'OFF'} + +
+ + {event && ( +
+ {event.label} + {formatCountdown(event.target - now)} +
+ )} + + {curfew.overrideActive && ( +
Override active
+ )} + + {curfew.enabled && !curfew.overrideActive && ( +
+ + +
+ )} + + {activeTimers.length > 0 && ( +
+ Active Bonus Timers + {activeTimers.map((t) => ( +
+ {t.category}: {formatCountdown(t.remainingMs)} + +
+ ))} +
+ )} + + +
+ ); +} diff --git a/frontend/src/components/DeviceCard.jsx b/frontend/src/components/DeviceCard.jsx new file mode 100644 index 0000000..8a7f881 --- /dev/null +++ b/frontend/src/components/DeviceCard.jsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; + +export default function DeviceCard({ device, onNuke, onUnnuke }) { + const [loading, setLoading] = useState(false); + const name = device.name || device.hostname || device.oui || 'Unknown Device'; + const isOnline = device.is_wired !== undefined || device._uptime_by_ugw !== undefined; + const isBlocked = device.blocked === true; + + const handleNuke = async () => { + setLoading(true); + try { + await onNuke(); + } finally { + setLoading(false); + } + }; + + const handleUnnuke = async () => { + setLoading(true); + try { + await onUnnuke(); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ +
+

{name}

+
+ {device.mac} + {device.ip && | {device.ip}} +
+
+
+ + {isBlocked ? ( + + ) : ( + + )} +
+ + +
+ ); +} diff --git a/frontend/src/components/RuleAssigner.jsx b/frontend/src/components/RuleAssigner.jsx new file mode 100644 index 0000000..993567b --- /dev/null +++ b/frontend/src/components/RuleAssigner.jsx @@ -0,0 +1,140 @@ +import { useState } from 'react'; +import { useApi } from '../hooks/useApi.js'; + +export default function RuleAssigner({ category, currentRuleIds, onSave, onClose }) { + const { data: rules, loading, error } = useApi('/rules'); + const [selected, setSelected] = useState(new Set(currentRuleIds)); + const [saving, setSaving] = useState(false); + + const toggleRule = (ruleId) => { + const next = new Set(selected); + if (next.has(ruleId)) { + next.delete(ruleId); + } else { + next.add(ruleId); + } + setSelected(next); + }; + + const handleSave = async () => { + setSaving(true); + try { + await onSave([...selected]); + } finally { + setSaving(false); + } + }; + + const ruleList = Array.isArray(rules) ? rules : []; + + return ( +
+
e.stopPropagation()}> +

Assign Rules: {category}

+ + {loading &&
Loading rules...
} + {error &&
{error}
} + +
+ {ruleList.length === 0 && !loading ? ( +
+ No traffic rules found. Connect to UDR to discover rules. +
+ ) : ( + ruleList.map((rule) => ( + + )) + )} +
+ +
+ + +
+ + +
+
+ ); +} diff --git a/frontend/src/hooks/useApi.js b/frontend/src/hooks/useApi.js new file mode 100644 index 0000000..0c95534 --- /dev/null +++ b/frontend/src/hooks/useApi.js @@ -0,0 +1,40 @@ +import { useState, useEffect, useCallback } from 'react'; + +const BASE = '/api'; + +export async function apiFetch(path, options = {}) { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json', ...options.headers }, + ...options, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + return res.json(); +} + +export function useApi(path, deps = []) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refetch = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await apiFetch(path); + setData(result); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, [path]); + + useEffect(() => { + refetch(); + }, [refetch, ...deps]); + + return { data, loading, error, refetch }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..334d774 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,205 @@ +:root { + --bg-primary: #2E473B; + --bg-secondary: #1E3229; + --bg-card: #3A5A4A; + --bg-card-hover: #456B57; + --text-primary: #F5F5DC; + --text-secondary: #C4C4A8; + --text-muted: #8A8A72; + --status-allowed: #4CAF50; + --status-blocked: #DC3545; + --status-nuke: #FF1744; + --accent: #6B8F71; + --border: #4A6B55; + --shadow: rgba(0, 0, 0, 0.3); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + font-family: 'Courier New', 'Consolas', monospace; + background: var(--bg-primary); + color: var(--text-primary); + -webkit-font-smoothing: antialiased; +} + +body { + background-image: + radial-gradient(circle at 20% 50%, rgba(107, 143, 113, 0.08) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(107, 143, 113, 0.05) 0%, transparent 50%), + linear-gradient(rgba(107, 143, 113, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(107, 143, 113, 0.03) 1px, transparent 1px); + background-size: 100% 100%, 100% 100%, 40px 40px, 40px 40px; +} + +#root { + height: 100%; +} + +.app-container { + display: flex; + flex-direction: column; + min-height: 100%; + max-width: 600px; + margin: 0 auto; +} + +.app-content { + flex: 1; + padding: 16px; + padding-bottom: 80px; + overflow-y: auto; +} + +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); +} + +.page-header h1 { + font-size: 1.4rem; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; +} + +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px; + margin-bottom: 12px; + transition: background 0.2s; +} + +.card:hover { + background: var(--bg-card-hover); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 20px; + border: none; + border-radius: 8px; + font-family: inherit; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.btn-allowed { + background: var(--status-allowed); + color: #fff; +} + +.btn-blocked { + background: var(--status-blocked); + color: #fff; +} + +.btn-nuke { + background: var(--status-nuke); + color: #fff; + box-shadow: 0 0 15px rgba(255, 23, 68, 0.4); +} + +.btn-nuke:hover { + box-shadow: 0 0 25px rgba(255, 23, 68, 0.6); +} + +.btn-secondary { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; +} + +.status-dot.online { + background: var(--status-allowed); + box-shadow: 0 0 6px var(--status-allowed); +} + +.status-dot.offline { + background: var(--text-muted); +} + +.toggle-switch { + position: relative; + width: 52px; + height: 28px; + background: var(--status-blocked); + border-radius: 14px; + cursor: pointer; + transition: background 0.3s; + border: none; + padding: 0; +} + +.toggle-switch.active { + background: var(--status-allowed); +} + +.toggle-switch .toggle-knob { + position: absolute; + top: 3px; + left: 3px; + width: 22px; + height: 22px; + background: #fff; + border-radius: 50%; + transition: transform 0.3s; +} + +.toggle-switch.active .toggle-knob { + transform: translateX(24px); +} + +.loading-spinner { + display: flex; + justify-content: center; + align-items: center; + padding: 40px; + color: var(--text-muted); +} + +.error-message { + background: rgba(220, 53, 69, 0.1); + border: 1px solid var(--status-blocked); + border-radius: 8px; + padding: 12px; + color: var(--status-blocked); + margin-bottom: 12px; +} + +@media (min-width: 768px) { + .app-content { + padding: 24px; + padding-bottom: 80px; + } +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..7497ae8 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.jsx'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +); diff --git a/frontend/src/pages/ControlDashboard.jsx b/frontend/src/pages/ControlDashboard.jsx new file mode 100644 index 0000000..4055baf --- /dev/null +++ b/frontend/src/pages/ControlDashboard.jsx @@ -0,0 +1,103 @@ +import { useState } from 'react'; +import { useApi, apiFetch } from '../hooks/useApi.js'; +import CategoryCard from '../components/CategoryCard.jsx'; +import CurfewClock from '../components/CurfewClock.jsx'; +import BonusTimeDrawer from '../components/BonusTimeDrawer.jsx'; +import RuleAssigner from '../components/RuleAssigner.jsx'; + +export default function ControlDashboard() { + const { data: policies, loading, error, refetch } = useApi('/policies'); + const { data: curfew, refetch: refetchCurfew } = useApi('/curfew'); + const { data: timers, refetch: refetchTimers } = useApi('/timers'); + const [bonusCategory, setBonusCategory] = useState(null); + const [assignCategory, setAssignCategory] = useState(null); + + const handleToggle = async (category, allowed) => { + try { + await apiFetch(`/policies/${encodeURIComponent(category)}/toggle`, { + method: 'POST', + body: JSON.stringify({ allowed }), + }); + refetch(); + } catch (err) { + console.error('Toggle failed:', err); + } + }; + + const handleBonusTime = async (category, minutes) => { + try { + await apiFetch('/timers', { + method: 'POST', + body: JSON.stringify({ category, minutes }), + }); + refetch(); + refetchTimers(); + setBonusCategory(null); + } catch (err) { + console.error('Bonus time failed:', err); + } + }; + + const handleCancelTimer = async (timerId) => { + try { + await apiFetch(`/timers/${timerId}`, { method: 'DELETE' }); + refetchTimers(); + refetch(); + } catch (err) { + console.error('Cancel timer failed:', err); + } + }; + + if (loading) return
Loading policies...
; + if (error) return
Error: {error}
; + + const categories = policies ? Object.entries(policies) : []; + + return ( +
+
+

// Control

+
+ + + +
+ {categories.map(([name, config]) => ( + t.category === name) || []} + onToggle={(allowed) => handleToggle(name, allowed)} + onBonusTime={() => setBonusCategory(name)} + onAssignRules={() => setAssignCategory(name)} + /> + ))} +
+ + {bonusCategory && ( + handleBonusTime(bonusCategory, minutes)} + onClose={() => setBonusCategory(null)} + /> + )} + + {assignCategory && ( + { + await apiFetch(`/policies/${encodeURIComponent(assignCategory)}/rules`, { + method: 'PUT', + body: JSON.stringify({ ruleIds }), + }); + refetch(); + setAssignCategory(null); + }} + onClose={() => setAssignCategory(null)} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/DeviceLibrary.jsx b/frontend/src/pages/DeviceLibrary.jsx new file mode 100644 index 0000000..ab0fdd5 --- /dev/null +++ b/frontend/src/pages/DeviceLibrary.jsx @@ -0,0 +1,121 @@ +import { useState } from 'react'; +import { useApi, apiFetch } from '../hooks/useApi.js'; +import DeviceCard from '../components/DeviceCard.jsx'; + +export default function DeviceLibrary() { + const { data: devices, loading, error, refetch } = useApi('/devices'); + const [search, setSearch] = useState(''); + + const handleNuke = async (mac) => { + if (!confirm(`NUKE device ${mac}? This will block all internet access.`)) return; + try { + await apiFetch(`/devices/${encodeURIComponent(mac)}/nuke`, { method: 'POST' }); + refetch(); + } catch (err) { + console.error('Nuke failed:', err); + } + }; + + const handleUnnuke = async (mac) => { + try { + await apiFetch(`/devices/${encodeURIComponent(mac)}/unnuke`, { method: 'POST' }); + refetch(); + } catch (err) { + console.error('Unnuke failed:', err); + } + }; + + if (loading) return
Discovering devices...
; + if (error) return
Error: {error}
; + + const deviceList = Array.isArray(devices) ? devices : []; + const filtered = deviceList.filter((d) => { + const term = search.toLowerCase(); + const name = (d.name || d.hostname || d.oui || '').toLowerCase(); + const mac = (d.mac || '').toLowerCase(); + const ip = (d.ip || '').toLowerCase(); + return name.includes(term) || mac.includes(term) || ip.includes(term); + }); + + return ( +
+
+

// Devices

+ +
+ +
+ setSearch(e.target.value)} + /> + {filtered.length} devices +
+ +
+ {filtered.length === 0 ? ( +
+ {deviceList.length === 0 + ? 'No devices found. Connect to UDR to discover devices.' + : 'No devices match your search.'} +
+ ) : ( + filtered.map((device) => ( + handleNuke(device.mac)} + onUnnuke={() => handleUnnuke(device.mac)} + /> + )) + )} +
+ + +
+ ); +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..cb3fbfe --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + '/api': { + target: 'http://backend:3001', + changeOrigin: true, + }, + }, + }, +});