← back to Butlr
lib/upload-watcher.js
104 lines
// Background folder watcher — polls data/uploads-meta.json.folder every
// intervalMs and imports any new file into the /admin/uploads gallery.
//
// Sharing the same dedup logic (size+mtime per source path) as the
// POST /admin/uploads/folder/sync endpoint. Best-effort — file errors are
// logged + skipped; the next scan retries.
//
// Wired in server.js after attachListenBridge(). HFM_NO_WATCHER=1
// disables (useful for tests).
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const UPLOADS_DIR = path.join(__dirname, '..', 'data', 'uploads');
const META_PATH = path.join(__dirname, '..', 'data', 'uploads-meta.json');
const ALLOWED_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.pdf', '.mp3', '.wav', '.mp4', '.txt', '.csv', '.json']);
const MAX_SIZE = 10 * 1024 * 1024;
function readMeta() {
try { return JSON.parse(fs.readFileSync(META_PATH, 'utf8')); } catch { return { files: [] }; }
}
function writeMeta(meta) {
const tmp = META_PATH + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(meta, null, 2));
fs.renameSync(tmp, META_PATH);
}
function scanOnce() {
const meta = readMeta();
if (!meta.folder) return { skipped_no_folder: true };
if (!fs.existsSync(meta.folder)) return { skipped_missing: meta.folder };
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
const existingKeys = new Set((meta.files || [])
.filter(f => f.source_path)
.map(f => `${f.source_size}:${f.source_mtime}`));
let entries;
try { entries = fs.readdirSync(meta.folder); }
catch (e) { return { error: 'readdir: ' + e.message }; }
const newEntries = [];
let imported = 0, skipped = 0;
for (const name of entries) {
if (name.startsWith('.')) continue;
const srcPath = path.join(meta.folder, name);
let st;
try { st = fs.statSync(srcPath); } catch { continue; }
if (!st.isFile()) continue;
if (st.size > MAX_SIZE) { continue; }
const ext = (path.extname(name) || '').toLowerCase();
if (!ALLOWED_EXT.has(ext)) { skipped++; continue; }
const key = `${st.size}:${st.mtime.getTime()}`;
if (existingKeys.has(key)) { skipped++; continue; }
try {
const newName = crypto.randomBytes(12).toString('hex') + ext;
fs.copyFileSync(srcPath, path.join(UPLOADS_DIR, newName));
newEntries.push({
filename: newName,
original_name: name,
size: st.size,
type: 'application/octet-stream',
uploaded_at: new Date().toISOString(),
source_path: srcPath,
source_size: st.size,
source_mtime: st.mtime.getTime(),
source: 'auto_watcher',
});
imported++;
} catch (e) {
console.error(`[upload-watcher] copy failed: ${name}: ${e.message}`);
}
}
if (newEntries.length) {
meta.files = (meta.files || []).concat(newEntries);
writeMeta(meta);
}
return { imported, skipped, folder: meta.folder };
}
let _interval = null;
function start({ intervalMs = 30_000 } = {}) {
if (process.env.HFM_NO_WATCHER === '1') return;
if (_interval) return;
console.log(`[upload-watcher] start · interval=${intervalMs}ms`);
_interval = setInterval(() => {
try {
const r = scanOnce();
if (r && r.imported > 0) {
console.log(`[upload-watcher] imported ${r.imported} from ${r.folder} (skipped ${r.skipped})`);
}
} catch (e) {
console.error('[upload-watcher] tick error:', e.message);
}
}, intervalMs);
// immediate first scan
setTimeout(() => { try { scanOnce(); } catch {} }, 1000);
}
function stop() { if (_interval) { clearInterval(_interval); _interval = null; } }
module.exports = { start, stop, scanOnce };