초기 버전 v1.0.0 구현
개인 NAS용 생존 확인 웹앱의 기본 기능, Docker 배포 설정, 설치 문서를 추가합니다.
This commit is contained in:
104
src/config.js
Normal file
104
src/config.js
Normal file
@@ -0,0 +1,104 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT_DIR = path.resolve(__dirname, '..');
|
||||
const DEFAULT_CONFIG_PATH = path.join(ROOT_DIR, 'config', 'default.json');
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
const configPath = process.env.CONFIG_PATH
|
||||
? path.resolve(process.env.CONFIG_PATH)
|
||||
: DEFAULT_CONFIG_PATH;
|
||||
|
||||
const fileConfig = fs.existsSync(configPath) ? readJson(configPath) : {};
|
||||
const thresholdSeconds = optionalNumber(
|
||||
process.env.THRESHOLD_SECONDS ?? fileConfig.threshold_seconds
|
||||
);
|
||||
|
||||
const config = {
|
||||
threshold_days: Number(process.env.THRESHOLD_DAYS ?? fileConfig.threshold_days ?? 90),
|
||||
threshold_seconds: thresholdSeconds,
|
||||
cooldown_seconds: Number(process.env.COOLDOWN_SECONDS ?? fileConfig.cooldown_seconds ?? 1),
|
||||
check_interval_seconds: Number(
|
||||
process.env.CHECK_INTERVAL_SECONDS ?? fileConfig.check_interval_seconds ?? 60
|
||||
),
|
||||
database_path: process.env.DATABASE_PATH ?? fileConfig.database_path ?? './data/keeper.sqlite',
|
||||
trigger_script: process.env.TRIGGER_SCRIPT ?? fileConfig.trigger_script ?? './scripts/cleanup.sh',
|
||||
background: process.env.BACKGROUND ?? fileConfig.background ?? 'random',
|
||||
collection_path: process.env.COLLECTION_PATH ?? fileConfig.collection_path ?? '/home/zenn/Collection',
|
||||
port: Number(process.env.PORT ?? fileConfig.port ?? 40100),
|
||||
recent_traces_limit: Number(
|
||||
process.env.RECENT_TRACES_LIMIT ?? fileConfig.recent_traces_limit ?? 5
|
||||
),
|
||||
reset_token: process.env.RESET_TOKEN ?? fileConfig.reset_token ?? '',
|
||||
root_dir: ROOT_DIR,
|
||||
config_path: configPath,
|
||||
};
|
||||
|
||||
config.database_path = resolvePath(config.database_path);
|
||||
config.trigger_script = resolvePath(config.trigger_script);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function optionalNumber(value) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function resolvePath(targetPath) {
|
||||
if (path.isAbsolute(targetPath)) {
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
return path.resolve(ROOT_DIR, targetPath);
|
||||
}
|
||||
|
||||
function listBackgrounds(rootDir) {
|
||||
const backgroundsDir = path.join(rootDir, 'assets', 'backgrounds');
|
||||
|
||||
if (!fs.existsSync(backgroundsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(backgroundsDir)
|
||||
.filter((file) => /\.(jpg|jpeg|png|webp|svg)$/i.test(file))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function pickBackground(config) {
|
||||
if (!config.background || config.background === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const backgrounds = listBackgrounds(config.root_dir);
|
||||
|
||||
if (backgrounds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.background === 'random') {
|
||||
return backgrounds[Math.floor(Math.random() * backgrounds.length)];
|
||||
}
|
||||
|
||||
if (backgrounds.includes(config.background)) {
|
||||
return config.background;
|
||||
}
|
||||
|
||||
return backgrounds[0];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadConfig,
|
||||
listBackgrounds,
|
||||
pickBackground,
|
||||
resolvePath,
|
||||
};
|
||||
283
src/db.js
Normal file
283
src/db.js
Normal file
@@ -0,0 +1,283 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const FALLBACK_EMOJIS = ['🐱', '🐰', '🐥', '🦊', '🍀', '🌸', '✨', '🎉', '💫', '🌈', '🍓', '🌙'];
|
||||
|
||||
function ensureDirectory(filePath) {
|
||||
const directory = path.dirname(filePath);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
}
|
||||
|
||||
function createDatabase(databasePath) {
|
||||
ensureDirectory(databasePath);
|
||||
|
||||
const db = new Database(databasePath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.pragma('busy_timeout = 5000');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
last_check_at TEXT NOT NULL,
|
||||
total_checks INTEGER NOT NULL DEFAULT 0,
|
||||
trigger_executed_for_check_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
accepted INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trigger_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
executed_at TEXT NOT NULL,
|
||||
script_path TEXT NOT NULL,
|
||||
success INTEGER NOT NULL DEFAULT 0,
|
||||
output TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
|
||||
`);
|
||||
|
||||
const existingState = db.prepare('SELECT id FROM state WHERE id = 1').get();
|
||||
|
||||
if (!existingState) {
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(`
|
||||
INSERT INTO state (id, last_check_at, total_checks, trigger_executed_for_check_at)
|
||||
VALUES (1, ?, 0, NULL)
|
||||
`).run(now);
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
function randomEmoji() {
|
||||
return FALLBACK_EMOJIS[Math.floor(Math.random() * FALLBACK_EMOJIS.length)];
|
||||
}
|
||||
|
||||
function getStateRow(db) {
|
||||
return db.prepare('SELECT * FROM state WHERE id = 1').get();
|
||||
}
|
||||
|
||||
function getRecentEvents(db, limit) {
|
||||
return db
|
||||
.prepare(`
|
||||
SELECT emoji, created_at, accepted
|
||||
FROM events
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
`)
|
||||
.all(limit);
|
||||
}
|
||||
|
||||
function daysBetween(fromDate, toDate) {
|
||||
const start = new Date(fromDate);
|
||||
const end = new Date(toDate);
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
return Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24)));
|
||||
}
|
||||
|
||||
function thresholdMilliseconds(config) {
|
||||
if (config.threshold_seconds) {
|
||||
return config.threshold_seconds * 1000;
|
||||
}
|
||||
|
||||
return config.threshold_days * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function deadlineFor(lastCheckAt, config) {
|
||||
return new Date(new Date(lastCheckAt).getTime() + thresholdMilliseconds(config));
|
||||
}
|
||||
|
||||
function isExpired(state, config, now = new Date()) {
|
||||
return now.getTime() >= deadlineFor(state.last_check_at, config).getTime();
|
||||
}
|
||||
|
||||
function buildStatus(db, config) {
|
||||
const state = getStateRow(db);
|
||||
const now = new Date();
|
||||
const lastCheckAt = new Date(state.last_check_at);
|
||||
const elapsedDays = daysBetween(state.last_check_at, now.toISOString());
|
||||
const deadlineAt = deadlineFor(lastCheckAt, config);
|
||||
const remainingMs = Math.max(0, deadlineAt.getTime() - now.getTime());
|
||||
const remainingDays = Math.floor(remainingMs / (24 * 60 * 60 * 1000));
|
||||
|
||||
return {
|
||||
last_check_at: state.last_check_at,
|
||||
deadline_at: deadlineAt.toISOString(),
|
||||
expired: remainingMs <= 0,
|
||||
last_check_display: formatDateTime(lastCheckAt),
|
||||
elapsed_days: elapsedDays,
|
||||
remaining_days: remainingDays,
|
||||
total_checks: state.total_checks,
|
||||
threshold_days: config.threshold_days,
|
||||
threshold_seconds: config.threshold_seconds,
|
||||
cooldown_seconds: config.cooldown_seconds,
|
||||
collection_path: config.collection_path,
|
||||
recent_traces: getRecentEvents(db, config.recent_traces_limit).map((event) => ({
|
||||
emoji: event.emoji,
|
||||
time: formatTime(new Date(event.created_at)),
|
||||
created_at: event.created_at,
|
||||
accepted: Boolean(event.accepted),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function formatDateTime(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function formatTime(date) {
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
function recordCheck(db, config, requestedEmoji) {
|
||||
const emoji =
|
||||
typeof requestedEmoji === 'string' && requestedEmoji.trim()
|
||||
? requestedEmoji.trim().slice(0, 16)
|
||||
: randomEmoji();
|
||||
|
||||
const insertEvent = db.prepare(`
|
||||
INSERT INTO events (emoji, created_at, accepted)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
|
||||
const updateState = db.prepare(`
|
||||
UPDATE state
|
||||
SET last_check_at = ?,
|
||||
total_checks = total_checks + 1,
|
||||
trigger_executed_for_check_at = NULL
|
||||
WHERE id = 1
|
||||
`);
|
||||
|
||||
let accepted = false;
|
||||
let expired = false;
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
const now = new Date();
|
||||
const nowIso = now.toISOString();
|
||||
const state = getStateRow(db);
|
||||
|
||||
if (isExpired(state, config, now)) {
|
||||
expired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const lastAcceptedAt = new Date(state.last_check_at);
|
||||
const secondsSinceLastCheck = (now.getTime() - lastAcceptedAt.getTime()) / 1000;
|
||||
accepted = secondsSinceLastCheck >= config.cooldown_seconds;
|
||||
|
||||
insertEvent.run(emoji, nowIso, accepted ? 1 : 0);
|
||||
|
||||
if (accepted) {
|
||||
updateState.run(nowIso);
|
||||
}
|
||||
});
|
||||
|
||||
transaction();
|
||||
|
||||
if (expired) {
|
||||
return {
|
||||
accepted: false,
|
||||
expired: true,
|
||||
emoji,
|
||||
message: '이미 시간이 지났어요.',
|
||||
status: buildStatus(db, config),
|
||||
};
|
||||
}
|
||||
|
||||
const message = accepted
|
||||
? `${emoji} 방금 확인했어요!`
|
||||
: `${emoji} 잠시 후 다시 눌러주세요.`;
|
||||
|
||||
return {
|
||||
accepted,
|
||||
expired: false,
|
||||
emoji,
|
||||
message,
|
||||
status: buildStatus(db, config),
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRunTrigger(db, config) {
|
||||
const state = getStateRow(db);
|
||||
|
||||
if (!isExpired(state, config)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.trigger_executed_for_check_at === state.last_check_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function markTriggerExecuted(db, scriptPath, success, output) {
|
||||
const nowIso = new Date().toISOString();
|
||||
const state = getStateRow(db);
|
||||
|
||||
const insertRun = db.prepare(`
|
||||
INSERT INTO trigger_runs (executed_at, script_path, success, output)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const updateState = db.prepare(`
|
||||
UPDATE state
|
||||
SET trigger_executed_for_check_at = ?
|
||||
WHERE id = 1
|
||||
`);
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
insertRun.run(nowIso, scriptPath, success ? 1 : 0, output ?? '');
|
||||
updateState.run(state.last_check_at);
|
||||
});
|
||||
|
||||
transaction();
|
||||
}
|
||||
|
||||
function resetKeeper(db) {
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
const resetState = db.prepare(`
|
||||
UPDATE state
|
||||
SET last_check_at = ?,
|
||||
total_checks = 0,
|
||||
trigger_executed_for_check_at = NULL
|
||||
WHERE id = 1
|
||||
`);
|
||||
|
||||
const deleteEvents = db.prepare('DELETE FROM events');
|
||||
const deleteTriggerRuns = db.prepare('DELETE FROM trigger_runs');
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
resetState.run(nowIso);
|
||||
deleteEvents.run();
|
||||
deleteTriggerRuns.run();
|
||||
});
|
||||
|
||||
transaction();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FALLBACK_EMOJIS,
|
||||
createDatabase,
|
||||
buildStatus,
|
||||
recordCheck,
|
||||
shouldRunTrigger,
|
||||
markTriggerExecuted,
|
||||
resetKeeper,
|
||||
};
|
||||
108
src/server.js
Normal file
108
src/server.js
Normal file
@@ -0,0 +1,108 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { loadConfig, pickBackground } = require('./config');
|
||||
const {
|
||||
createDatabase,
|
||||
buildStatus,
|
||||
recordCheck,
|
||||
shouldRunTrigger,
|
||||
markTriggerExecuted,
|
||||
resetKeeper,
|
||||
} = require('./db');
|
||||
const { runTriggerScript } = require('./trigger');
|
||||
const { getVersionInfo } = require('./version');
|
||||
|
||||
const config = loadConfig();
|
||||
const db = createDatabase(config.database_path);
|
||||
const app = express();
|
||||
|
||||
let triggerRunning = false;
|
||||
const sessionBackground = pickBackground(config);
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(config.root_dir, 'public')));
|
||||
app.use('/assets', express.static(path.join(config.root_dir, 'assets')));
|
||||
|
||||
app.get('/api/version', (_req, res) => {
|
||||
res.json(getVersionInfo());
|
||||
});
|
||||
|
||||
app.get('/api/status', (_req, res) => {
|
||||
res.json({
|
||||
...buildStatus(db, config),
|
||||
...getVersionInfo(),
|
||||
background: sessionBackground,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/check', (req, res) => {
|
||||
const result = recordCheck(db, config, req.body?.emoji);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
app.post('/api/reset', (req, res) => {
|
||||
if (!isResetAllowed(req)) {
|
||||
res.status(403).json({ error: 'Reset token is invalid.' });
|
||||
return;
|
||||
}
|
||||
|
||||
resetKeeper(db);
|
||||
res.json({
|
||||
ok: true,
|
||||
status: buildStatus(db, config),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(config.root_dir, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
function isResetAllowed(req) {
|
||||
if (!config.reset_token) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const token = req.body?.token ?? req.get('x-reset-token');
|
||||
return token === config.reset_token;
|
||||
}
|
||||
|
||||
async function evaluateTrigger() {
|
||||
if (triggerRunning || !shouldRunTrigger(db, config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
triggerRunning = true;
|
||||
|
||||
try {
|
||||
console.log(`[keeper] Threshold exceeded. Running script: ${config.trigger_script}`);
|
||||
const result = await runTriggerScript(config.trigger_script, config.collection_path);
|
||||
markTriggerExecuted(db, config.trigger_script, result.success, result.output);
|
||||
|
||||
if (result.success) {
|
||||
console.log('[keeper] Trigger script completed successfully.');
|
||||
} else {
|
||||
console.error('[keeper] Trigger script failed:', result.output);
|
||||
}
|
||||
} catch (error) {
|
||||
markTriggerExecuted(db, config.trigger_script, false, error.message);
|
||||
console.error('[keeper] Trigger execution error:', error.message);
|
||||
} finally {
|
||||
triggerRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
evaluateTrigger().catch((error) => {
|
||||
console.error('[keeper] Trigger scheduler error:', error.message);
|
||||
});
|
||||
}, config.check_interval_seconds * 1000);
|
||||
|
||||
app.listen(config.port, () => {
|
||||
const { name, version } = getVersionInfo();
|
||||
console.log(`[keeper] ${name} v${version} listening on port ${config.port}`);
|
||||
console.log(`[keeper] Database: ${config.database_path}`);
|
||||
console.log(`[keeper] Trigger script: ${config.trigger_script}`);
|
||||
evaluateTrigger().catch((error) => {
|
||||
console.error('[keeper] Initial trigger check failed:', error.message);
|
||||
});
|
||||
});
|
||||
53
src/trigger.js
Normal file
53
src/trigger.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
function runTriggerScript(scriptPath, collectionPath) {
|
||||
return new Promise((resolve) => {
|
||||
if (!fs.existsSync(scriptPath)) {
|
||||
resolve({
|
||||
success: false,
|
||||
output: `Script not found: ${scriptPath}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn('sh', [scriptPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
COLLECTION_PATH: collectionPath,
|
||||
KEEPER_SCRIPT: scriptPath,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
resolve({
|
||||
success: false,
|
||||
output: error.message,
|
||||
});
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
const output = [stdout.trim(), stderr.trim()].filter(Boolean).join('\n');
|
||||
resolve({
|
||||
success: code === 0,
|
||||
output: output || `Script exited with code ${code}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runTriggerScript,
|
||||
};
|
||||
12
src/version.js
Normal file
12
src/version.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const packageJson = require('../package.json');
|
||||
|
||||
function getVersionInfo() {
|
||||
return {
|
||||
name: packageJson.name,
|
||||
version: packageJson.version,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getVersionInfo,
|
||||
};
|
||||
Reference in New Issue
Block a user