feat(panel): reskin backend — node sortIndex, presence/speed, /api/system, credentials, subscription aggregation (Node+Go parity)
This commit is contained in:
@@ -7,11 +7,15 @@ const net = require('node:net');
|
||||
const { Store } = require('./lib/store');
|
||||
const { hashSecret, verifySecret, randomToken, SessionManager } = require('./lib/auth');
|
||||
const { sendJson, sendError, readJson, route, serveStatic } = require('./lib/http');
|
||||
const { PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, ensureNodeSecrets, newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, dropRealityKey } = require('./lib/protocols');
|
||||
const { Orchestrator, audit, id, nowIso, queueJob, forwardNode, rebuildNode, selectPort, suspendCustomerResources, resumeCustomerResources } = require('./lib/orchestrator');
|
||||
const { PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, ensureNodeSecrets, newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, dropRealityKey, nodeEmail } = require('./lib/protocols');
|
||||
const { Orchestrator, audit, id, nowIso, queueJob, forwardNode, rebuildNode, selectPort, suspendCustomerResources, resumeCustomerResources, alignCustomerNodes } = require('./lib/orchestrator');
|
||||
const { formatSubscription, nodeClientUri } = require('./lib/subscriptions');
|
||||
const { redactSecrets } = require('./lib/redact');
|
||||
const { clientIdentity, activeClients, observedIps, pruneAccess } = require('./lib/access');
|
||||
const { parseResetDay, armTrafficReset, applyMonthlyTrafficResets } = require('./lib/reset');
|
||||
const { customerOnline, customerSpeedBps, nodeSpeedBps, nodeOnlineCustomerIds } = require('./lib/presence');
|
||||
const { SystemMonitor } = require('./lib/sysstat');
|
||||
const { fetchExternalSource, dueSources, applyFetchResult, normalizeExternalSources, normalizeManualLinks } = require('./lib/extsub');
|
||||
|
||||
const APP_ROOT = __dirname;
|
||||
const PUBLIC_DIR = path.join(APP_ROOT, 'public');
|
||||
@@ -24,6 +28,7 @@ const VERSION = '0.6.8';
|
||||
const store = new Store(DATA_FILE);
|
||||
let sessions;
|
||||
let orchestrator;
|
||||
let systemMonitor = null;
|
||||
const loginAttempts = new Map();
|
||||
|
||||
function cleanText(value, max = 120) {
|
||||
@@ -70,6 +75,7 @@ function normalizeServer(body, current = {}) {
|
||||
publicAddress: 'publicAddress' in body ? requiredText(body.publicAddress, '公网地址', 255) : current.publicAddress,
|
||||
publicAddressV6: 'publicAddressV6' in body ? cleanText(body.publicAddressV6, 255) : (current.publicAddressV6 || ''),
|
||||
tlsDomain: 'tlsDomain' in body ? cleanText(body.tlsDomain, 253).toLowerCase() : (current.tlsDomain || ''),
|
||||
enabled: 'enabled' in body ? body.enabled !== false : (current.enabled !== undefined ? current.enabled : true),
|
||||
portRangeStart: 'portRangeStart' in body ? Number(body.portRangeStart) : Number(current.portRangeStart || 20000),
|
||||
portRangeEnd: 'portRangeEnd' in body ? Number(body.portRangeEnd) : Number(current.portRangeEnd || 50000),
|
||||
labels: 'labels' in body ? asIds(body.labels).slice(0, 20) : (current.labels || [])
|
||||
@@ -134,14 +140,47 @@ function publicNode(item) {
|
||||
|
||||
function publicServer(item) {
|
||||
return { ...item, ...(item.engine ? { engine: { ...item.engine, detail: redactSecrets(item.engine.detail) } } : {}),
|
||||
enabled: item.enabled !== false,
|
||||
agentVersion: store.data.agents.find((agent) => agent.serverId === item.id && agent.status === 'active')?.version || null,
|
||||
pendingCleanup: store.data.nodes.filter((entry) => (entry.serverId === item.id || entry.exitServerId === item.id) && entry.status === 'removing').length };
|
||||
}
|
||||
|
||||
function publicCustomer(item) {
|
||||
return { ...item,
|
||||
const now = Date.now();
|
||||
const speed = customerSpeedBps(item, now);
|
||||
const out = { ...item,
|
||||
observedIpCount: observedIps(store.data, item.id).length,
|
||||
subscriptionClientCount: activeClients(store.data, item.id).length };
|
||||
subscriptionClientCount: activeClients(store.data, item.id).length,
|
||||
online: customerOnline(item, now),
|
||||
lastActiveAt: item.lastOnlineAt || null,
|
||||
speedUpBps: speed.up, speedDownBps: speed.down };
|
||||
if ((item.externalSources || []).length) {
|
||||
out.externalStatus = {};
|
||||
for (const source of item.externalSources) {
|
||||
const cache = (store.data.subSourceCache || {})[source.id];
|
||||
out.externalStatus[source.id] = cache
|
||||
? { fetchedAt: cache.fetchedAt || null, okAt: cache.okAt || null, failCount: cache.failCount || 0,
|
||||
lastError: cache.lastError || null, droppedCount: cache.droppedCount || 0, count: (cache.items || []).length }
|
||||
: { fetchedAt: null, okAt: null, failCount: 0, lastError: null, droppedCount: 0, count: 0 };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// One customer's credential rows (凭据 tab): the subscription token plus
|
||||
// one row per node binding, holding that binding's own credentials.
|
||||
function credentialItems(data, customer) {
|
||||
return data.nodeClients.filter((nc) => nc.customerId === customer.id).map((nc) => {
|
||||
const node = data.nodes.find((item) => item.id === nc.nodeId);
|
||||
return {
|
||||
nodeClientId: nc.id, nodeId: nc.nodeId, nodeName: node ? node.name : nc.nodeId,
|
||||
protocol: node ? node.protocol : '', status: nc.status,
|
||||
clientId: nc.credentials ? nc.credentials.clientId || '' : '',
|
||||
password: nc.credentials ? nc.credentials.relayPassword || '' : '',
|
||||
shortId: nc.credentials ? nc.credentials.shortId || '' : '',
|
||||
username: node && node.protocol === 'socks5-auth' ? nodeEmail(customer.id) : '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function customerBlockReason(data, customer) {
|
||||
@@ -398,6 +437,10 @@ async function handleAgent(req, res, pathname) {
|
||||
const existing = data.observations.find((item) => item.customerId === customerId && item.ip === ip);
|
||||
if (existing) existing.lastSeenAt = nowIso();
|
||||
else data.observations.push({ id: id('obs'), customerId, ip, firstSeenAt: nowIso(), lastSeenAt: nowIso() });
|
||||
// An accepted observation is a live connection on this server:
|
||||
// it is the customer's freshest presence signal.
|
||||
const observedCustomer = data.customers.find((item) => item.id === customerId);
|
||||
if (observedCustomer) observedCustomer.lastOnlineAt = nowIso();
|
||||
affected.add(customerId);
|
||||
}
|
||||
for (const customerId of affected) {
|
||||
@@ -418,6 +461,33 @@ async function handleAgent(req, res, pathname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fetch one external source now and persist the outcome. Used by the
|
||||
// manual refresh endpoint (awaited) and by save/housekeeping triggers.
|
||||
async function refreshOneSource(customerId, sourceId) {
|
||||
const customer = store.data.customers.find((item) => item.id === customerId);
|
||||
const source = customer && (customer.externalSources || []).find((item) => item.id === sourceId);
|
||||
if (!source) return null;
|
||||
const result = await fetchExternalSource(source, store.data.settings);
|
||||
await store.transaction((data) => { applyFetchResult(data, sourceId, result, nowIso()); });
|
||||
return result;
|
||||
}
|
||||
|
||||
function refreshSourcesInBackground(customerId, sourceIds) {
|
||||
for (const sourceId of sourceIds || []) {
|
||||
refreshOneSource(customerId, sourceId).catch((error) => console.error('External subscription refresh failed:', error));
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDueSources() {
|
||||
try {
|
||||
for (const { customerId, source } of dueSources(store.data, Date.now(), 3)) {
|
||||
await refreshOneSource(customerId, source.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('External subscription refresh failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdminApi(req, res, pathname) {
|
||||
if (!pathname.startsWith('/api/')) return false;
|
||||
const changing = !['GET', 'HEAD'].includes(req.method);
|
||||
@@ -440,6 +510,11 @@ async function handleAdminApi(req, res, pathname) {
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (req.method === 'GET' && pathname === '/api/system') {
|
||||
if (!systemMonitor) systemMonitor = new SystemMonitor(path.dirname(DATA_FILE));
|
||||
sendJson(res, 200, systemMonitor.status(store.data, VERSION));
|
||||
return true;
|
||||
}
|
||||
if (req.method === 'GET' && pathname === '/api/protocols') {
|
||||
sendJson(res, 200, { profiles: PROFILE_CATALOG, realityPresets: REALITY_PRESETS });
|
||||
return true;
|
||||
@@ -602,7 +677,7 @@ async function handleAdminApi(req, res, pathname) {
|
||||
}
|
||||
if (req.method === 'POST' && pathname === '/api/customers') {
|
||||
const body = await readJson(req);
|
||||
const customer = await store.transaction((data) => {
|
||||
const customer = await store.transaction(async (data) => {
|
||||
const next = {
|
||||
id: id('cus'), subscriptionToken: randomToken(32), name: requiredText(body.name, '客户名称'), group: cleanText(body.group, 80),
|
||||
status: 'active', trafficLimitBytes: nonNegativeNumber(body.trafficLimitBytes, '流量上限'), usedBytes: 0, usedUplinkBytes: 0, usedDownlinkBytes: 0,
|
||||
@@ -611,16 +686,25 @@ async function handleAdminApi(req, res, pathname) {
|
||||
tags: asIds(body.tags).slice(0, 20), notes: cleanText(body.notes, 1000),
|
||||
createdAt: nowIso(), updatedAt: nowIso()
|
||||
};
|
||||
const resetDay = parseResetDay(body.trafficResetDay);
|
||||
if (resetDay > 0) { next.trafficResetDay = resetDay; armTrafficReset(next); }
|
||||
const sources = normalizeExternalSources(body.externalSources, id);
|
||||
if (sources && sources.length) next.externalSources = sources;
|
||||
const links = normalizeManualLinks(body.manualLinks, id);
|
||||
if (links && links.length) next.manualLinks = links;
|
||||
data.customers.push(next);
|
||||
if (Array.isArray(body.nodeIds)) await alignCustomerNodes(data, next, body.nodeIds, actor);
|
||||
audit(data, actor, 'create_customer', next.id, { name: next.name });
|
||||
return structuredClone(next);
|
||||
});
|
||||
refreshSourcesInBackground(customer.id, (customer.externalSources || []).map((source) => source.id));
|
||||
sendJson(res, 201, { customer });
|
||||
return true;
|
||||
}
|
||||
const customerItem = route('/api/customers/:id', pathname);
|
||||
if (customerItem && req.method === 'PATCH') {
|
||||
const body = await readJson(req);
|
||||
let changedSourceIds = [];
|
||||
const customer = await store.transaction(async (data) => {
|
||||
const item = data.customers.find((entry) => entry.id === customerItem.id);
|
||||
if (!item) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
|
||||
@@ -634,6 +718,32 @@ async function handleAdminApi(req, res, pathname) {
|
||||
if ('deviceLimit' in body) item.deviceLimit = nonNegativeNumber(body.deviceLimit, '设备上限', true);
|
||||
if ('expiresAt' in body) item.expiresAt = optionalIso(body.expiresAt, '到期日期');
|
||||
if ('tags' in body) item.tags = asIds(body.tags).slice(0, 20);
|
||||
if ('trafficResetDay' in body) {
|
||||
const resetDay = parseResetDay(body.trafficResetDay);
|
||||
if (resetDay !== (item.trafficResetDay || 0)) {
|
||||
// A changed schedule is re-armed rather than fired (mirrors
|
||||
// panel-go); an unchanged echo leaves the marker alone.
|
||||
if (resetDay > 0) item.trafficResetDay = resetDay; else delete item.trafficResetDay;
|
||||
armTrafficReset(item);
|
||||
}
|
||||
}
|
||||
if ('externalSources' in body) {
|
||||
const previous = new Map((item.externalSources || []).map((source) => [source.id, source]));
|
||||
const sources = normalizeExternalSources(body.externalSources, id) || [];
|
||||
if (sources.length) item.externalSources = sources; else delete item.externalSources;
|
||||
const kept = new Set(sources.map((source) => source.id));
|
||||
if (data.subSourceCache) {
|
||||
for (const oldId of previous.keys()) if (!kept.has(oldId)) delete data.subSourceCache[oldId];
|
||||
}
|
||||
changedSourceIds = sources.filter((source) => {
|
||||
const old = previous.get(source.id);
|
||||
return !old || old.url !== source.url || (old.userAgent || '') !== (source.userAgent || '');
|
||||
}).map((source) => source.id);
|
||||
}
|
||||
if ('manualLinks' in body) {
|
||||
const links = normalizeManualLinks(body.manualLinks, id) || [];
|
||||
if (links.length) item.manualLinks = links; else delete item.manualLinks;
|
||||
}
|
||||
const reason = customerBlockReason(data, item);
|
||||
if (body.status === 'active' && reason) {
|
||||
const error = new Error(`客户仍受${{ expired:'到期时间', traffic_limit:'流量额度', ip_limit:'节点 IP 上限' }[reason]}限制,请先修改限制再启用`);
|
||||
@@ -646,10 +756,12 @@ async function handleAdminApi(req, res, pathname) {
|
||||
}
|
||||
if (wasActive && item.status === 'suspended') suspendCustomerResources(data, item, actor);
|
||||
if (!wasActive && item.status === 'active') await resumeCustomerResources(data, item, actor);
|
||||
if (Array.isArray(body.nodeIds)) await alignCustomerNodes(data, item, body.nodeIds, actor);
|
||||
item.updatedAt = nowIso();
|
||||
audit(data, actor, 'update_customer', item.id);
|
||||
return structuredClone(item);
|
||||
});
|
||||
refreshSourcesInBackground(customer.id, changedSourceIds);
|
||||
sendJson(res, 200, { customer });
|
||||
return true;
|
||||
}
|
||||
@@ -686,24 +798,98 @@ async function handleAdminApi(req, res, pathname) {
|
||||
data.nodes.some((node) => node.id === item.nodeId && !node.pendingDelete))) {
|
||||
throw Object.assign(new Error('客户仍有节点分配。请先移除全部分配'), { statusCode: 409 });
|
||||
}
|
||||
const removed = data.customers[index];
|
||||
data.customers.splice(index, 1);
|
||||
data.subscriptionAccess = data.subscriptionAccess.filter((item) => item.customerId !== customerItem.id);
|
||||
data.subscriptionClients = data.subscriptionClients.filter((item) => item.customerId !== customerItem.id);
|
||||
data.observations = data.observations.filter((item) => item.customerId !== customerItem.id);
|
||||
if (data.subSourceCache) {
|
||||
for (const source of removed.externalSources || []) delete data.subSourceCache[source.id];
|
||||
}
|
||||
audit(data, actor, 'delete_customer', customerItem.id);
|
||||
});
|
||||
sendJson(res, 200, { ok: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
const credentialsRoute = route('/api/customers/:id/credentials', pathname);
|
||||
if (credentialsRoute && req.method === 'GET') {
|
||||
const customer = store.data.customers.find((item) => item.id === credentialsRoute.id);
|
||||
if (!customer) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
|
||||
sendJson(res, 200, { customerId: customer.id, subscriptionToken: customer.subscriptionToken,
|
||||
items: credentialItems(store.data, customer) });
|
||||
return true;
|
||||
}
|
||||
const regenerateRoute = route('/api/customers/:id/credentials/:ncid/regenerate', pathname);
|
||||
if (regenerateRoute && req.method === 'POST') {
|
||||
const item = await store.transaction(async (data) => {
|
||||
const customer = data.customers.find((entry) => entry.id === regenerateRoute.id);
|
||||
if (!customer) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
|
||||
const nc = data.nodeClients.find((entry) => entry.id === regenerateRoute.ncid && entry.customerId === customer.id);
|
||||
if (!nc) throw Object.assign(new Error('凭据不存在'), { statusCode: 404 });
|
||||
const node = data.nodes.find((entry) => entry.id === nc.nodeId);
|
||||
if (!node) throw Object.assign(new Error('节点不存在'), { statusCode: 404 });
|
||||
// Same generation rules as a fresh assignment; the node resource is
|
||||
// rebuilt so the old credential stops working on the wire.
|
||||
nc.credentials = newNodeClientCredentials(node.protocol);
|
||||
nc.updatedAt = nowIso();
|
||||
if (['active', 'deploying', 'failed', 'degraded'].includes(node.status)) await rebuildNode(data, node);
|
||||
audit(data, actor, 'regenerate_credentials', customer.id, { nodeId: node.id });
|
||||
return credentialItems(data, customer).find((entry) => entry.nodeClientId === nc.id);
|
||||
});
|
||||
sendJson(res, 200, { item });
|
||||
return true;
|
||||
}
|
||||
const sourceRefresh = route('/api/customers/:id/external-sources/:sid/refresh', pathname);
|
||||
if (sourceRefresh && req.method === 'POST') {
|
||||
const customer = store.data.customers.find((item) => item.id === sourceRefresh.id);
|
||||
if (!customer) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
|
||||
if (!(customer.externalSources || []).some((item) => item.id === sourceRefresh.sid)) {
|
||||
throw Object.assign(new Error('外部订阅不存在'), { statusCode: 404 });
|
||||
}
|
||||
const result = await refreshOneSource(customer.id, sourceRefresh.sid);
|
||||
sendJson(res, 200, { ok: Boolean(result && result.ok), count: result ? result.items.length : 0,
|
||||
dropped: result ? result.dropped : 0, error: result ? result.error : '刷新失败' });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && pathname === '/api/nodes') {
|
||||
const visibleNodes = store.data.nodes.filter((item) => !item.pendingDelete);
|
||||
const now = Date.now();
|
||||
const visibleNodes = store.data.nodes.filter((item) => !item.pendingDelete)
|
||||
.sort((a, b) => (Number.isFinite(a.sortIndex) ? a.sortIndex : Number.MAX_SAFE_INTEGER) - (Number.isFinite(b.sortIndex) ? b.sortIndex : Number.MAX_SAFE_INTEGER));
|
||||
const visibleIds = new Set(visibleNodes.map((item) => item.id));
|
||||
const nodeClients = store.data.nodeClients.filter((nc) => visibleIds.has(nc.nodeId)).map((nc) => {
|
||||
const { credentials, ...safe } = nc;
|
||||
return { ...safe, clientUri: nodeClientUri(store.data, nc) };
|
||||
});
|
||||
sendJson(res, 200, { nodes: visibleNodes.map(publicNode), nodeClients });
|
||||
const nodes = visibleNodes.map((node) => {
|
||||
const onlineCustomerIds = nodeOnlineCustomerIds(store.data, node, now);
|
||||
const speed = nodeSpeedBps(node, now);
|
||||
return { ...publicNode(node),
|
||||
clientCount: store.data.nodeClients.filter((nc) => nc.nodeId === node.id && nc.status === 'active').length,
|
||||
onlineCount: onlineCustomerIds.length, onlineCustomerIds,
|
||||
speedUpBps: speed.up, speedDownBps: speed.down };
|
||||
});
|
||||
sendJson(res, 200, { nodes, nodeClients });
|
||||
return true;
|
||||
}
|
||||
if (req.method === 'POST' && pathname === '/api/nodes/reorder') {
|
||||
const body = await readJson(req);
|
||||
const ids = asIds(body.ids);
|
||||
if (!ids.length) throw Object.assign(new Error('排序列表不能为空'), { statusCode: 400 });
|
||||
await store.transaction((data) => {
|
||||
const byId = new Map(data.nodes.map((node) => [node.id, node]));
|
||||
for (const nodeId of ids) {
|
||||
if (!byId.has(nodeId)) throw Object.assign(new Error('节点不存在'), { statusCode: 400 });
|
||||
}
|
||||
const listed = new Set(ids);
|
||||
const rest = data.nodes.filter((node) => !listed.has(node.id))
|
||||
.sort((a, b) => (Number.isFinite(a.sortIndex) ? a.sortIndex : Number.MAX_SAFE_INTEGER) - (Number.isFinite(b.sortIndex) ? b.sortIndex : Number.MAX_SAFE_INTEGER));
|
||||
[...ids.map((nodeId) => byId.get(nodeId)), ...rest].forEach((node, index) => { node.sortIndex = index; });
|
||||
audit(data, actor, 'reorder_nodes', 'nodes', { count: ids.length });
|
||||
return null;
|
||||
});
|
||||
sendJson(res, 200, { ok: true });
|
||||
return true;
|
||||
}
|
||||
if (req.method === 'POST' && pathname === '/api/nodes') {
|
||||
@@ -713,6 +899,7 @@ async function handleAdminApi(req, res, pathname) {
|
||||
const next = {
|
||||
id: id('nod'), status: 'draft', entryReady: false, exitReady: false,
|
||||
resourceId: id('res'), exitResourceId: id('res'),
|
||||
sortIndex: data.nodes.reduce((max, node) => Math.max(max, Number.isFinite(node.sortIndex) ? node.sortIndex : -1), -1) + 1,
|
||||
...normalized,
|
||||
artifacts: {}, error: null, pendingDelete: false,
|
||||
usageCounters: null, retiredUsageEpochs: [], meteredTraffic: null, lastUsageAt: null,
|
||||
@@ -1066,6 +1253,16 @@ async function housekeeping() {
|
||||
try {
|
||||
await store.transaction(async (data) => {
|
||||
const now = Date.now();
|
||||
// Monthly traffic resets run before the quota evaluation below,
|
||||
// mirroring panel-go housekeeping.
|
||||
const resetDone = applyMonthlyTrafficResets(data, new Date(), (draft, customer, previousUsed, day, period) =>
|
||||
audit(draft, 'system:monthly_reset', 'reset_customer_usage_monthly', customer.id, { previousUsedBytes: previousUsed, resetDay: day, period }));
|
||||
for (const customer of resetDone) {
|
||||
if (customer.status === 'suspended' && customer.suspendReason === 'traffic_limit' && !customerBlockReason(data, customer)) {
|
||||
customer.status = 'active'; customer.suspendReason = null; customer.updatedAt = nowIso();
|
||||
await resumeCustomerResources(data, customer, 'system:monthly_reset');
|
||||
}
|
||||
}
|
||||
for (const server of data.servers) {
|
||||
if (server.lastSeenAt && now - new Date(server.lastSeenAt).getTime() > 180000) server.status = 'offline';
|
||||
}
|
||||
@@ -1103,6 +1300,9 @@ async function housekeeping() {
|
||||
return null;
|
||||
});
|
||||
sessions.prune();
|
||||
if (!systemMonitor) systemMonitor = new SystemMonitor(path.dirname(DATA_FILE));
|
||||
systemMonitor.sample(store.data);
|
||||
await refreshDueSources();
|
||||
} catch (error) {
|
||||
console.error('Housekeeping failed:', error);
|
||||
}
|
||||
@@ -1126,6 +1326,14 @@ async function main() {
|
||||
if (store.data.customers.some((item) => !item.subscriptionToken)) {
|
||||
await store.transaction((data) => { for (const customer of data.customers) if (!customer.subscriptionToken) customer.subscriptionToken = randomToken(32); });
|
||||
}
|
||||
// Node custom order: legacy records carry no sortIndex. Renumber by the
|
||||
// stored array order whenever the values are not exactly 0..n-1.
|
||||
const sortValues = store.data.nodes.map((node) => node.sortIndex);
|
||||
const sortBroken = !sortValues.every((value) => Number.isFinite(value)) ||
|
||||
[...sortValues].sort((a, b) => a - b).some((value, index) => value !== index);
|
||||
if (store.data.nodes.length && sortBroken) {
|
||||
await store.transaction((data) => { data.nodes.forEach((node, index) => { node.sortIndex = index; }); });
|
||||
}
|
||||
if (!store.data.users.length) {
|
||||
const password = process.env.NG_ADMIN_PASSWORD || randomToken(15);
|
||||
await store.transaction((data) => {
|
||||
|
||||
Reference in New Issue
Block a user