초기 버전 v1.0.0 구현

개인 NAS용 생존 확인 웹앱의 기본 기능, Docker 배포 설정, 설치 문서를 추가합니다.
This commit is contained in:
2026-06-10 11:48:06 +09:00
commit f7ee8cb923
24 changed files with 3593 additions and 0 deletions

104
src/config.js Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,12 @@
const packageJson = require('../package.json');
function getVersionInfo() {
return {
name: packageJson.name,
version: packageJson.version,
};
}
module.exports = {
getVersionInfo,
};