diff --git a/README.md b/README.md index 71fa164..20368ff 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,13 @@ # SyncTool -The software that runs on my Pi USB Parking station project. Written in Node. \ No newline at end of file +The software that runs on my Pi USB Parking station project. Written in Node. + +## Features +- Highly configurable +- Doesn't rely on anything Node itself doesn't include +- Spins up and manges Copyparty, Rclone, Rsync, and Open Notebook upload. +- (probably) doesn't suck. +### code transparency +Most of the services/ modules were written by Gemini +index.js was written by me and most of usbkey-lifecycle.js +sysfs-functions was writen by gemini \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..0e5ab6a --- /dev/null +++ b/config.json @@ -0,0 +1,63 @@ +{ + "system": { + "mountPoint": "/mnt/flashdrive", + "ledPath": "/sys/class/leds/ACT", + "cacheFile": "./data/sync_state.json", + "targetUuid": "7AEB-FF21", + "maxFailedMounts": 2 + }, + + "copyparty": { + "enabled": true, + "binaryPath": "/opt/usb-syncer/copyparty-sfx.py", + "port": 3923, + "user": "pi", + "password": "changeme", + "volumeName": "flash", + "permissions": "rwmd", + "extraArgs": ["--no-reload", "--q"] + }, + + "rsync": { + "enabled": true, + "nasPath": "/mnt/nas/flash_backup", + "bidirectional": true, + "deleteRemote": false, + "bypassEmptyFolder": true, + "exclude": [ + ".Trash-*", + "System Volume Information", + ".Spotlight-V100", + ".fseventsd", + "*.tmp" + ], + "flags": ["-avu", "--update", "--modify-window=1"] + }, + + "rclone": { + "enabled": true, + "remote": "gdrive:FlashBackup", + "bidirectional": true, + "bandwidthLimit": null, + "exclude": [ + ".*", + "*.tmp" + ], + "flags": ["--update", "--transfers=2", "--checkers=4"] + }, + + "openNotebook": { + "enabled": true, + "endpoint": "http://192.168.1.50:8000/api/v1", + "apiToken": "env:NOTEBOOK_TOKEN", + "notebookId": "class-notes-2026", + "watchFolder": "classes", + "allowedExtensions": [".pdf", ".docx", ".md", ".txt"] + }, + + "notifications": { + "enabled": false, + "webhookUrl": "https://ntfy.sh/my-sync-appliance" + } +} + diff --git a/index.js b/index.js new file mode 100644 index 0000000..6dbf42b --- /dev/null +++ b/index.js @@ -0,0 +1,107 @@ +// SyncTool +// Read or modify this code if you dare, it may be unilicense but you'll loose your sanity +import { execSync, spawn } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, openAsBlob } from 'node:fs'; +import { isMounted,unmountDrive,mountDrive, driveExists } from './usbkey-lifecycle'; +import { startCopyparty, stopCopyparty } from './services/copyparty'; //Realistically we should just use promise for this to keep things optimized. But, who cares about optimization! +import { start } +import data from './config.json' with { type: 'json' } +import path from 'node:path'; +import { start } from 'node:repl'; +import { runRclone, runRsync } from './services/syncjobs'; +console.log('SyncTool init') +console.log("Don't blame me if stuff bursts into flames") +console.log("System info:") +// Print enabled servers +const serverConfigMap = { + 'Open Notebook': 'openNotebook', + 'Rclone': 'rclone', + 'Rsync': 'rsync', + 'Copyparty': 'copyparty' +} +Object.entries(serverConfigMap).forEach(([server, configKey]) => { + const isEnabled = data[configKey]?.enabled === true + console.log(`${isEnabled ? '✓' : '✗'} ${server} ${isEnabled ? '(enabled)' : '(disabled)'}`) +}) + + + +//Check if the Flash Drive is plugged in +if (isMounted(config.system.targetUuid, config.system.mountPoint) == true) { + console.log("Drive looks mounted.") + var USBKeyMounted = true +} else { + if (driveExists != null) { + var FDDevNode = driveExists() + var mountAttempts = 0 + console.debug("Mount attempts: ", mountAttempts) + try { + mountDrive(FDDevNode, config.mountPoint) + } catch (error) { + mountAttempts++ + console.debug("Mount attempts: ", mountAttempts) + if (mountAttempts >= config.maxFailedMounts) { + console.error("Failed to mount the drive... Did it disconnect?") + exit(1) + } + } // Otherwise the drive exists and we can move on + } + } +//If RSYNC is on, check if the NAS is mounted and empty: +if (config.rsync.enabled == true) { + const nasPath = config.rsync.nasPath + console.log(`Checking NAS at ${nasPath}:`) + try { + const files = readdirSync(nasPath) + if (files.length === 0) { + console.warn("NAS is empty!") + } else { + console.log(`Found ${files.length} items in NAS`) + } + } catch (error) { + console.error(`Error checking NAS: ${error.message}`) + } +} +// Now it's Loadin time +// The best part about index.js was when it said "it's loadin time" and loaded all over the place +var copypartyStarted = false +var OpenNotebookstarted = false +var rcloneStarted = false +var rcloneRuns = 0 +var rSYNcStarted = false +var rSYNcRuns = 0 +function loadintime() { + //WELCOME TO if HELL!!!! + if (config.copyparty.enabled == true) { + console.debug("Copyparty enabled, starting") + startCopyparty(config) + } + if (config.rclone.enabled == true) { + // Go ahead and run both once + rcloneRuns++ + console.debug("Starting first run for Rclone...") + try { + await runRclone(config) + var rcloneStarted = true + } catch (error) { + console.warn("Rclone went up in flames: ", error.message) + var rcloneStarted = false + } + } + if (config.rsync.enabled == true) { + rSYNcRuns++ + console.debug("Starting first run for Rsync...") + try { + await runRsync(config) + var rSYNcStarted = true + } catch (error) { + console.warn("Rsync went up in flames: ", error.message) + var rSYNcStarted = false + } + } + if (config.openNotebook.enabled == true) { + //do whatever is needed here + } + console.debug("loadin time complete") +} +//TODO: Actually do something? idiot \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..a252168 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "synctool", + "version": "0.1.0", + "description": "The software that runs on my Pi USB Parking station project. Written in Node.", + "repository": { + "type": "git", + "url": "https://warehouse.randomhack.com/unfunny/SyncTool.git" + }, + "license": "Unlicense", + "author": "", + "type": "module", + "main": "index.js", + "scripts": { + "test": "notify-send \"I AM NOT A MORON!\" \"COULD A MORON, [forget to set up the test command]? HUH? COULD A MORON DO THAT? uh oh\"" + } +} diff --git a/services/copyparty.js b/services/copyparty.js new file mode 100644 index 0000000..c1e385c --- /dev/null +++ b/services/copyparty.js @@ -0,0 +1,45 @@ +// services/copyparty.js +import { spawn } from 'node:child_process'; + +let processHandle = null; + +export function startCopyparty(config) { + const cp = config.copyparty; + if (!cp?.enabled || processHandle) return; + + const mountPoint = config.system.mountPoint; + + // volflag :c,hist=/tmp keeps sqlite write hits off the flash drive and sd card + const volSpec = `${mountPoint}:${cp.volumeName}:${cp.permissions}:c,hist=/tmp/copyparty`; + + const args = [ + cp.binaryPath, + '-p', String(cp.port), + '-a', `${cp.user}:${cp.password}`, + '-v', volSpec, + ...(cp.extraArgs || []) + ]; + + console.log(`[COPYPARTY] Starting server on port ${cp.port}...`); + processHandle = spawn('python3', args, { + stdio: 'ignore', // swap to 'inherit' if debugging startup flags + detached: false + }); + + processHandle.on('error', (err) => { + console.error('[COPYPARTY] Failed to spawn:', err.message); + processHandle = null; + }); + + processHandle.on('exit', (code) => { + console.log(`[COPYPARTY] Process exited (code ${code})`); + processHandle = null; + }); +} + +export function stopCopyparty() { + if (!processHandle) return; + console.log('[COPYPARTY] Stopping server...'); + processHandle.kill('SIGTERM'); + processHandle = null; +} \ No newline at end of file diff --git a/services/syncjobs.js b/services/syncjobs.js new file mode 100644 index 0000000..01f90ff --- /dev/null +++ b/services/syncjobs.js @@ -0,0 +1,58 @@ +// services/syncJobs.js +// This file handles both +import { spawn } from 'node:child_process'; + +function runCmd(bin, args) { + return new Promise((resolve, reject) => { + console.log(`[EXEC] ${bin} ${args.join(' ')}`); + const proc = spawn(bin, args, { stdio: 'inherit' }); + proc.on('error', reject); + proc.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`${bin} failed with exit code ${code}`)); + }); + }); +} + +export async function runRsync(config) { + const cfg = config.rsync; + if (!cfg?.enabled) return; + + const mountPoint = config.system.mountPoint; + const excludes = (cfg.exclude || []).flatMap((pattern) => ['--exclude', pattern]); + + console.log('[RSYNC] Syncing with NAS...'); + await runCmd('rsync', [...cfg.flags, ...excludes, `${mountPoint}/`, `${cfg.nasPath}/`]); + + if (cfg.bidirectional) { + await runCmd('rsync', [ + ...cfg.flags, + '--ignore-existing', + ...excludes, + `${cfg.nasPath}/`, + `${mountPoint}/` + ]); + } +} + +export async function runRclone(config) { + const cfg = config.rclone; + if (!cfg?.enabled) return; + + const mountPoint = config.system.mountPoint; + const excludes = (cfg.exclude || []).flatMap((pattern) => ['--exclude', pattern]); + + console.log('[RCLONE] Syncing with cloud...'); + await runCmd('rclone', ['copy', ...cfg.flags, ...excludes, mountPoint, cfg.remote]); + + if (cfg.bidirectional) { + await runCmd('rclone', [ + 'copy', + '--ignore-existing', + ...cfg.flags, + ...excludes, + cfg.remote, + mountPoint + ]); + } +} \ No newline at end of file diff --git a/sysfs-functions.js b/sysfs-functions.js new file mode 100644 index 0000000..99962de --- /dev/null +++ b/sysfs-functions.js @@ -0,0 +1,80 @@ +// sysfs functions +const fs = require('node:fs'); + +const LED_PATH = fs.existsSync('/sys/class/leds/ACT') + ? '/sys/class/leds/ACT' + : '/sys/class/leds/led0'; + +// detach kernel trigger once at startup +try { + fs.writeFileSync(`${LED_PATH}/trigger`, 'none'); +} catch (err) { + console.error(`[LED] failed to claim sysfs led:`, err.message); +} + +let blinkTimer = null; + +function setBrightness(val) { + try { + fs.writeFileSync(`${LED_PATH}/brightness`, val.toString()); + } catch (err) { + console.error(`[LED] error writing brightness:`, err.message); + } +} + +function turnOff() { + stopBlink(); + setBrightness(0); +} + +function turnOn() { + stopBlink(); + setBrightness(1); +} + +function getState() { + try { + return parseInt(fs.readFileSync(`${LED_PATH}/brightness`, 'utf8').trim(), 10); + } catch { + return null; + } +} + +function toggle() { + setBrightness(getState() === 1 ? 0 : 1); +} + +function startBlink(interval = 500) { + stopBlink(); + let state = 1; + setBrightness(state); + + blinkTimer = setInterval(() => { + state = state === 1 ? 0 : 1; + setBrightness(state); + }, interval); +} + +function stopBlink() { + if (blinkTimer) { + clearInterval(blinkTimer); + blinkTimer = null; + } +} + +function restoreDefault() { + stopBlink(); + try { + fs.writeFileSync(`${LED_PATH}/trigger`, 'mmc0'); + } catch {} +} + +module.exports = { + turnOff, + turnOn, + toggle, + getState, + startBlink, + stopBlink, + restoreDefault +}; \ No newline at end of file diff --git a/usbkey-lifecycle.js b/usbkey-lifecycle.js new file mode 100644 index 0000000..01f6f56 --- /dev/null +++ b/usbkey-lifecycle.js @@ -0,0 +1,61 @@ +// handles mounting/unmounting the USB drive +import { execSync } from 'node:child_process'; +import { mkdirSync, rmdirSync, readFileSync, realpathSync, existsSync } from 'node:fs'; + +export function isMounted(mountPath) { + try { + return readFileSync('/proc/mounts', 'utf8').includes(` ${mountPath} `); + } catch { + return false; + } +} + +export function mountDrive(devNode, mountPath) { + if (isMounted(mountPath)) { + console.log(`[MOUNT] ${mountPath} is already mounted.`); + return true; + } + + try { + // ensure the directory exists first + mkdirSync(mountPath, { recursive: true }); + + // mount with open permissions so copyparty/rsync don't hit EACCES + execSync(`mount -o umask=000,uid=1000,gid=1000 ${devNode} ${mountPath}`); + console.log(`[MOUNT] success: ${devNode} -> ${mountPath}`); + return true; + } catch (err) { + console.error(`[MOUNT] failed:`, err.message); + return false; + } +} + +export function unmountDrive(mountPath) { + if (!isMounted(mountPath)) { + // cleanup orphaned directory just in case + try { rmdirSync(mountPath); } catch {} + return; + } + + try { + console.log(`[UNMOUNT] flushing buffers and unmounting ${mountPath}...`); + execSync('sync'); // force kernel to write any pending data to the usb + execSync(`umount -l ${mountPath}`); // lazy unmount in case a process is stuck reading + rmdirSync(mountPath); + console.log(`[UNMOUNT] cleanly unmounted and removed directory.`); + } catch (err) { + console.error(`[UNMOUNT] failed:`, err.message); + } +} + +export function driveExists(targetuid) { + const linuidpath = '/dev/disk/by-uid/${targetuid}' + if (!existsSync(linuidpath)) { + return null; + } + try { + return realpathSync(linuidpath) + } catch { + return null; + } +} \ No newline at end of file