168 lines
5.0 KiB
JavaScript
168 lines
5.0 KiB
JavaScript
/**
|
|
* SQLite blob store (logical S3 keys → rows in `blobs`).
|
|
*/
|
|
|
|
const crypto = require('crypto');
|
|
const { openDatabase } = require('../db/sqliteDb');
|
|
|
|
function sha256Etag(buf) {
|
|
const h = crypto.createHash('sha256').update(buf).digest('hex');
|
|
return `"${h}"`;
|
|
}
|
|
|
|
function namespaceFromKey(objectKey) {
|
|
const k = String(objectKey || '');
|
|
if (k.startsWith('ping-cache/') || k.startsWith('speed-test-cache/') || k.startsWith('network-map-cache/') ||
|
|
k.startsWith('uptime-monitor-cache/') || k.startsWith('ping-services/')) {
|
|
return 'cache';
|
|
}
|
|
if (k.startsWith('filter-manager/config-') || k.startsWith('filter-manager/server-filters-')) {
|
|
return 'per_server';
|
|
}
|
|
if (k === 'mikrotik-frouting-config.txt') {
|
|
return 'derived';
|
|
}
|
|
return 'config';
|
|
}
|
|
|
|
function isVersionedKey(db, objectKey) {
|
|
const row = db.prepare('SELECT 1 FROM versioned_keys WHERE object_key = ?').get(objectKey);
|
|
return Boolean(row);
|
|
}
|
|
|
|
function trimVersions(db, objectKey, keep = 50) {
|
|
db.prepare(
|
|
`DELETE FROM blob_versions WHERE object_key = ? AND id NOT IN (
|
|
SELECT id FROM blob_versions WHERE object_key = ? ORDER BY created_at DESC, id DESC LIMIT ?
|
|
)`
|
|
).run(objectKey, objectKey, keep);
|
|
}
|
|
|
|
/**
|
|
* @param {Buffer|string} body
|
|
*/
|
|
function writeBlobTx(db, objectKey, body, contentType) {
|
|
const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf8');
|
|
const ns = namespaceFromKey(objectKey);
|
|
const etag = sha256Etag(buf);
|
|
const now = Date.now();
|
|
const size = buf.length;
|
|
|
|
const existing = db.prepare('SELECT body, content_type, etag, updated_at, created_at FROM blobs WHERE object_key = ?').get(objectKey);
|
|
|
|
const run = () => {
|
|
if (existing && isVersionedKey(db, objectKey)) {
|
|
const oldBuf = existing.body;
|
|
db.prepare(
|
|
`INSERT INTO blob_versions (object_key, body, byte_size, etag, created_at)
|
|
VALUES (?, ?, ?, ?, ?)`
|
|
).run(objectKey, oldBuf, oldBuf.length, existing.etag, existing.updated_at);
|
|
trimVersions(db, objectKey);
|
|
}
|
|
|
|
if (existing) {
|
|
db.prepare(
|
|
`UPDATE blobs SET namespace = ?, content_type = ?, body = ?, byte_size = ?, etag = ?, updated_at = ?
|
|
WHERE object_key = ?`
|
|
).run(ns, contentType, buf, size, etag, now, objectKey);
|
|
} else {
|
|
db.prepare(
|
|
`INSERT INTO blobs (object_key, namespace, content_type, body, byte_size, etag, updated_at, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
).run(objectKey, ns, contentType, buf, size, etag, now, now);
|
|
}
|
|
};
|
|
|
|
db.transaction(run)();
|
|
return {
|
|
etag,
|
|
lastModified: new Date(now).toISOString(),
|
|
contentLength: size,
|
|
};
|
|
}
|
|
|
|
function readBlob(objectKey) {
|
|
const db = openDatabase();
|
|
const row = db.prepare(
|
|
'SELECT body, etag, updated_at, byte_size FROM blobs WHERE object_key = ?'
|
|
).get(objectKey);
|
|
if (!row) return null;
|
|
const bodyStr = row.body.toString('utf8');
|
|
return {
|
|
body: bodyStr,
|
|
etag: row.etag,
|
|
lastModified: new Date(row.updated_at).toISOString(),
|
|
contentLength: row.byte_size,
|
|
};
|
|
}
|
|
|
|
function headMetaRow(objectKey) {
|
|
const db = openDatabase();
|
|
const row = db.prepare('SELECT etag, updated_at, byte_size FROM blobs WHERE object_key = ?').get(objectKey);
|
|
if (!row) {
|
|
return { etag: null, lastModified: null, contentLength: null };
|
|
}
|
|
return {
|
|
etag: row.etag,
|
|
lastModified: new Date(row.updated_at).toISOString(),
|
|
contentLength: row.byte_size,
|
|
};
|
|
}
|
|
|
|
function headEtag(objectKey) {
|
|
const m = headMetaRow(objectKey);
|
|
return m.etag || undefined;
|
|
}
|
|
|
|
function deleteBlob(objectKey) {
|
|
const db = openDatabase();
|
|
db.prepare('DELETE FROM blobs WHERE object_key = ?').run(objectKey);
|
|
}
|
|
|
|
function listByPrefix(prefix, maxKeys = 100) {
|
|
const db = openDatabase();
|
|
const rows = db.prepare(
|
|
`SELECT object_key AS key, byte_size AS size, updated_at, etag FROM blobs
|
|
WHERE object_key >= ? AND object_key < ? ORDER BY object_key LIMIT ?`
|
|
).all(prefix, prefix + '\uffff', maxKeys);
|
|
return rows.map((r) => ({
|
|
key: r.key,
|
|
size: r.size,
|
|
lastModified: new Date(r.updated_at).toISOString(),
|
|
etag: r.etag,
|
|
}));
|
|
}
|
|
|
|
function listVersions(objectKey, maxKeys = 50) {
|
|
const db = openDatabase();
|
|
return db.prepare(
|
|
`SELECT id, object_key, byte_size, etag, created_at FROM blob_versions
|
|
WHERE object_key = ? ORDER BY created_at DESC, id DESC LIMIT ?`
|
|
).all(objectKey, maxKeys);
|
|
}
|
|
|
|
function rollbackToVersionId(objectKey, versionId) {
|
|
const db = openDatabase();
|
|
const vid = Number(versionId);
|
|
if (!Number.isFinite(vid)) throw new Error('Invalid versionId');
|
|
const ver = db.prepare('SELECT body, etag, created_at FROM blob_versions WHERE id = ? AND object_key = ?').get(vid, objectKey);
|
|
if (!ver) throw new Error('Version not found');
|
|
const buf = ver.body;
|
|
const contentType = objectKey.endsWith('.json') ? 'application/json' : 'text/plain';
|
|
writeBlobTx(db, objectKey, buf, contentType);
|
|
return headMetaRow(objectKey);
|
|
}
|
|
|
|
module.exports = {
|
|
namespaceFromKey,
|
|
writeBlobTx,
|
|
readBlob,
|
|
headMetaRow,
|
|
headEtag,
|
|
deleteBlob,
|
|
listByPrefix,
|
|
listVersions,
|
|
rollbackToVersionId,
|
|
sha256Etag,
|
|
};
|