usagi-server / database.js
eienmojiki's picture
Initial deploy
9ecc700
/**
* database.js
* JSON file-based database module (replaces database.php)
* Uses atomic writes and file locking for concurrency safety.
*/
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
// Git sync for persistent storage on GitHub
let gitSync = null;
try {
gitSync = require("./git-sync");
} catch (e) {
// git-sync module not available, no-op
}
class Database {
constructor(dataDir) {
this.dataDir = dataDir;
this.usersFile = path.join(dataDir, "users.json");
this.keysFile = path.join(dataDir, "license_keys.json");
this.hitsFile = path.join(dataDir, "hits.json");
this.settingsFile = path.join(dataDir, "settings.json");
this.binLibraryFile = path.join(dataDir, "bin_library.json");
this.hitCountsFile = path.join(dataDir, "hit_counts.json");
// Ensure data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
}
// ─── Atomic file read/write ───────────────────────────────────
_readJson(filePath) {
try {
if (!fs.existsSync(filePath)) return [];
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw);
return parsed;
} catch (e) {
return [];
}
}
_writeJson(filePath, data) {
const tmp = filePath + ".tmp." + process.pid;
try {
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
fs.renameSync(tmp, filePath);
// Schedule git sync after successful write
if (gitSync && gitSync.scheduleSync) gitSync.scheduleSync();
return true;
} catch (e) {
try {
fs.unlinkSync(tmp);
} catch (_) {}
return false;
}
}
_readObj(filePath) {
try {
if (!fs.existsSync(filePath)) return {};
const raw = fs.readFileSync(filePath, "utf-8");
return JSON.parse(raw) || {};
} catch (e) {
return {};
}
}
// ─── Users ─────────────────────────────────────────────────────
getAllUsers() {
return this._readJson(this.usersFile);
}
getUser(userId) {
const users = this.getAllUsers();
return users.find((u) => String(u.user_id) === String(userId)) || null;
}
getUserByToken(token) {
const users = this.getAllUsers();
return users.find((u) => u.token === token) || null;
}
isTokenExists(token) {
return !!this.getUserByToken(token);
}
createUser(data) {
const users = this.getAllUsers();
const user = {
user_id: data.user_id,
username: data.username || "",
first_name: data.first_name || "",
pfp_url: data.pfp_url || "",
token: data.token || null,
hits: 0,
attempts: 0,
banned: false,
created_at: new Date().toISOString(),
last_active: new Date().toISOString(),
};
users.push(user);
this._writeJson(this.usersFile, users);
return user;
}
updateUser(userId, updates) {
const users = this.getAllUsers();
const idx = users.findIndex((u) => String(u.user_id) === String(userId));
if (idx === -1) return false;
Object.assign(users[idx], updates);
this._writeJson(this.usersFile, users);
return true;
}
// ─── Hits ──────────────────────────────────────────────────────
getAllHits() {
return this._readJson(this.hitsFile);
}
addHitRecord(userId, details) {
const hits = this.getAllHits();
hits.push({
user_id: String(userId),
...details,
timestamp: new Date().toISOString(),
});
this._writeJson(this.hitsFile, hits);
}
getHitCounts(userId) {
const counts = this._readObj(this.hitCountsFile);
return {
global_hits: counts.global || 0,
user_hits: userId ? counts.users?.[String(userId)] || 0 : 0,
};
}
incrementStat(userId, field) {
const users = this.getAllUsers();
const idx = users.findIndex((u) => String(u.user_id) === String(userId));
if (idx === -1) return 0;
users[idx][field] = (users[idx][field] || 0) + 1;
this._writeJson(this.usersFile, users);
// Also update hit_counts.json for fast O(1) reads
if (field === "hits") {
const counts = this._readObj(this.hitCountsFile);
if (!counts.users) counts.users = {};
counts.users[String(userId)] = users[idx].hits;
counts.global = (counts.global || 0) + 1;
this._writeJson(this.hitCountsFile, counts);
}
return users[idx][field];
}
getUserHits(userId, limit = 50) {
const hits = this.getAllHits();
return hits
.filter((h) => String(h.user_id) === String(userId))
.slice(-limit)
.reverse();
}
getUserHitsTotal(userId) {
const hits = this.getAllHits();
return hits.filter((h) => String(h.user_id) === String(userId)).length;
}
// ─── License Keys ──────────────────────────────────────────────
getAllLicenseKeys() {
return this._readJson(this.keysFile);
}
createLicenseKey(key, version, active = true) {
const keys = this.getAllLicenseKeys();
keys.push({
key,
version,
active,
created_at: new Date().toISOString(),
});
this._writeJson(this.keysFile, keys);
}
validateLicenseKey(key) {
const keys = this.getAllLicenseKeys();
const found = keys.find((k) => k.key === key);
if (!found) return { valid: false };
return { valid: found.active !== false, version: found.version };
}
revokeLicenseKey(key) {
const keys = this.getAllLicenseKeys();
const idx = keys.findIndex((k) => k.key === key);
if (idx === -1) return false;
keys[idx].active = false;
this._writeJson(this.keysFile, keys);
return true;
}
activateLicenseKey(key) {
const keys = this.getAllLicenseKeys();
const idx = keys.findIndex((k) => k.key === key);
if (idx === -1) return false;
keys[idx].active = true;
this._writeJson(this.keysFile, keys);
return true;
}
deleteLicenseKey(key) {
const keys = this.getAllLicenseKeys();
const idx = keys.findIndex((k) => k.key === key);
if (idx === -1) return false;
keys.splice(idx, 1);
this._writeJson(this.keysFile, keys);
return true;
}
// ─── Settings ──────────────────────────────────────────────────
getSetting(name) {
const settings = this._readObj(this.settingsFile);
return settings[name] || null;
}
setSetting(name, value) {
const settings = this._readObj(this.settingsFile);
settings[name] = value;
this._writeJson(this.settingsFile, settings);
}
getAllSettings() {
return this._readObj(this.settingsFile);
}
// ─── BIN Library ───────────────────────────────────────────────
getBinLibrary() {
return this._readJson(this.binLibraryFile);
}
saveBinLibrary(bins) {
this._writeJson(this.binLibraryFile, bins);
}
addBinToLibrary(binObj) {
const bins = this.getBinLibrary();
bins.push(binObj);
return this._writeJson(this.binLibraryFile, bins);
}
removeBinFromLibrary(binId, binNumber) {
const bins = this.getBinLibrary();
const idx = bins.findIndex(
(b) => (binId && b.id === binId) || (binNumber && b.bin === binNumber),
);
if (idx === -1) return null;
const removed = bins.splice(idx, 1)[0];
this._writeJson(this.binLibraryFile, bins);
return removed;
}
clearBinLibrary() {
return this._writeJson(this.binLibraryFile, []);
}
// ─── Stats / Leaderboard ───────────────────────────────────────
getGlobalStats() {
const users = this.getAllUsers();
const hits = this.getAllHits();
let totalAttempts = 0;
let totalHits = 0;
for (const u of users) {
totalAttempts += u.attempts || 0;
totalHits += u.hits || 0;
}
return {
total_users: users.length,
total_attempts: totalAttempts,
total_hits: totalHits,
};
}
getTopUsers(limit = 10) {
const users = this.getAllUsers();
return users
.filter((u) => (u.hits || 0) > 0)
.sort((a, b) => (b.hits || 0) - (a.hits || 0))
.slice(0, limit);
}
getUserRank(userId) {
const users = this.getAllUsers();
const sorted = users
.filter((u) => (u.hits || 0) > 0)
.sort((a, b) => (b.hits || 0) - (a.hits || 0));
const idx = sorted.findIndex((u) => String(u.user_id) === String(userId));
if (idx === -1) return null;
return {
rank: idx + 1,
user_id: String(sorted[idx].user_id),
username: sorted[idx].username || "",
hits: sorted[idx].hits || 0,
};
}
// ─── Admin ─────────────────────────────────────────────────────
clearAllTokensAndStats() {
const users = this.getAllUsers();
let count = 0;
for (const u of users) {
u.token = null;
u.hits = 0;
u.attempts = 0;
count++;
}
this._writeJson(this.usersFile, users);
this._writeJson(this.hitCountsFile, { global: 0, users: {} });
return count;
}
cleanFakeHits() {
const hits = this.getAllHits();
const before = hits.length;
const cleaned = hits.filter(
(h) => h.full_card && h.full_card.length >= 13 && h.full_card.includes("|"),
);
this._writeJson(this.hitsFile, cleaned);
return before - cleaned.length;
}
resetDatabase() {
const files = [this.usersFile, this.keysFile, this.hitsFile, this.hitCountsFile];
let deleted = 0;
for (const f of files) {
if (fs.existsSync(f)) {
fs.unlinkSync(f);
deleted++;
}
}
return deleted;
}
rebuildHitCounters() {
const users = this.getAllUsers();
const counts = { global: 0, users: {} };
let usersWithHits = 0;
for (const u of users) {
const h = u.hits || 0;
if (h > 0) {
counts.users[String(u.user_id)] = h;
counts.global += h;
usersWithHits++;
}
}
this._writeJson(this.hitCountsFile, counts);
return { total_hits: counts.global, users_with_hits: usersWithHits };
}
migrateOldHitCounters() {
// No-op for JS version (migration was for old PHP data)
}
autoSyncHitCounters() {
if (!fs.existsSync(this.hitCountsFile)) {
this.rebuildHitCounters();
}
}
}
module.exports = Database;