Update
This commit is contained in:
@@ -18,6 +18,12 @@ const selectSuperuser = db.prepare(`
|
||||
const selectUserByUsername = db.prepare('SELECT * FROM users WHERE username = ?');
|
||||
const selectUserByEmail = db.prepare('SELECT * FROM users WHERE email = ?');
|
||||
const selectUserById = db.prepare('SELECT * FROM users WHERE id = ?');
|
||||
const updateLastLogin = db.prepare(`
|
||||
UPDATE users
|
||||
SET last_login = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const insertUser = db.prepare(`
|
||||
INSERT INTO users (username, email, password_hash, password_salt, is_active, created_at, updated_at)
|
||||
@@ -31,8 +37,8 @@ const updateUser = db.prepare(`
|
||||
`);
|
||||
|
||||
const insertMembership = db.prepare(`
|
||||
INSERT OR IGNORE INTO user_group_memberships (user_id, group_id, role)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT OR IGNORE INTO user_group_memberships (user_id, group_id)
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
|
||||
const removeMembershipsForGroup = db.prepare(`
|
||||
@@ -70,24 +76,48 @@ const creationTransaction = db.transaction(({ username, email, passwordHash, pas
|
||||
}
|
||||
|
||||
removeMembershipsForGroup.run(groupId);
|
||||
insertMembership.run(userId, groupId, SUPERUSER_GROUP_NAME);
|
||||
insertMembership.run(userId, groupId);
|
||||
|
||||
return selectUserById.get(userId);
|
||||
});
|
||||
|
||||
function hashPassword(password) {
|
||||
export function hashPassword(password) {
|
||||
if (!password || typeof password !== 'string') {
|
||||
throw new Error('INVALID_PASSWORD');
|
||||
const err = new Error('INVALID_PASSWORD');
|
||||
err.code = 'INVALID_PASSWORD';
|
||||
throw err;
|
||||
}
|
||||
const trimmed = password.trim();
|
||||
if (!trimmed) {
|
||||
const err = new Error('INVALID_PASSWORD');
|
||||
err.code = 'INVALID_PASSWORD';
|
||||
throw err;
|
||||
}
|
||||
if (trimmed.length < 8) {
|
||||
throw new Error('PASSWORD_TOO_SHORT');
|
||||
const err = new Error('PASSWORD_TOO_SHORT');
|
||||
err.code = 'PASSWORD_TOO_SHORT';
|
||||
throw err;
|
||||
}
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.pbkdf2Sync(trimmed, salt, 120000, 64, 'sha512').toString('hex');
|
||||
return { salt, hash };
|
||||
}
|
||||
|
||||
export function verifyPassword(password, hash, salt) {
|
||||
if (!hash || !salt) return false;
|
||||
if (!password || typeof password !== 'string') return false;
|
||||
const trimmed = password.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
const derived = crypto.pbkdf2Sync(trimmed, salt, 120000, 64, 'sha512').toString('hex');
|
||||
const storedBuffer = Buffer.from(hash, 'hex');
|
||||
const derivedBuffer = Buffer.from(derived, 'hex');
|
||||
if (storedBuffer.length !== derivedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
return crypto.timingSafeEqual(storedBuffer, derivedBuffer);
|
||||
}
|
||||
|
||||
function ensureGroup(name, description = null) {
|
||||
let group = selectGroupByName.get(name);
|
||||
if (group) return group;
|
||||
@@ -101,6 +131,25 @@ export function hasSuperuser() {
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
export function findUserByIdentifier(identifier) {
|
||||
const value = String(identifier || '').trim();
|
||||
if (!value) return null;
|
||||
if (value.includes('@')) {
|
||||
return selectUserByEmail.get(value.toLowerCase());
|
||||
}
|
||||
return selectUserByUsername.get(value);
|
||||
}
|
||||
|
||||
export function findUserById(id) {
|
||||
if (!id) return null;
|
||||
return selectUserById.get(id);
|
||||
}
|
||||
|
||||
export function markUserLogin(userId) {
|
||||
if (!userId) return;
|
||||
updateLastLogin.run(userId);
|
||||
}
|
||||
|
||||
function createOrUpdateSuperuser({ username, email, password }) {
|
||||
const normalizedUsername = String(username || '').trim();
|
||||
const normalizedEmail = String(email || '').trim().toLowerCase();
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -58,6 +58,45 @@ CREATE TABLE IF NOT EXISTS user_group_memberships (
|
||||
);
|
||||
`;
|
||||
|
||||
|
||||
const createServersTable = `
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
const createEndpointsTable = `
|
||||
CREATE TABLE IF NOT EXISTS endpoints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
external_id TEXT NOT NULL,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||
UNIQUE (server_id, external_id)
|
||||
);
|
||||
`;
|
||||
|
||||
|
||||
const createServerApiKeysTable = `
|
||||
CREATE TABLE IF NOT EXISTS server_api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL UNIQUE,
|
||||
key_cipher TEXT NOT NULL,
|
||||
key_iv TEXT NOT NULL,
|
||||
key_tag TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
const createUserSettingsTable = `
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id INTEGER NOT NULL,
|
||||
@@ -97,6 +136,15 @@ console.log('✅ user_groups table ready');
|
||||
db.exec(createUserGroupMembershipsTable);
|
||||
console.log('✅ user_group_memberships table ready');
|
||||
|
||||
db.exec(createServersTable);
|
||||
console.log('✅ servers table ready');
|
||||
|
||||
db.exec(createEndpointsTable);
|
||||
console.log('✅ endpoints table ready');
|
||||
|
||||
db.exec(createServerApiKeysTable);
|
||||
console.log('✅ server_api_keys table ready');
|
||||
|
||||
db.exec(createUserSettingsTable);
|
||||
console.log('✅ user_settings table ready');
|
||||
|
||||
|
||||
+586
-26
@@ -11,7 +11,17 @@ import os from 'os';
|
||||
import crypto from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { db } from './db/index.js';
|
||||
import { ensureSuperuserFromEnv, getSuperuserSummary, hasSuperuser, registerSuperuser, removeSuperuser } from './auth/superuser.js';
|
||||
import {
|
||||
ensureSuperuserFromEnv,
|
||||
getSuperuserSummary,
|
||||
hasSuperuser,
|
||||
registerSuperuser,
|
||||
removeSuperuser,
|
||||
findUserByIdentifier,
|
||||
findUserById,
|
||||
markUserLogin,
|
||||
verifyPassword
|
||||
} from './auth/superuser.js';
|
||||
import {
|
||||
logRedeployEvent,
|
||||
buildLogFilter,
|
||||
@@ -21,10 +31,25 @@ import {
|
||||
} from './db/redeployLogs.js';
|
||||
import { getSetting, setSetting, deleteSetting } from './db/settings.js';
|
||||
import { activateMaintenanceMode, deactivateMaintenanceMode, getMaintenanceState, isMaintenanceModeActive } from './maintenance/state.js';
|
||||
import {
|
||||
ensureDefaultsFromEnv,
|
||||
getActiveEndpointExternalId,
|
||||
getActiveApiKey,
|
||||
getActiveServerUrl,
|
||||
hasServer,
|
||||
hasEndpoint,
|
||||
hasApiKey,
|
||||
getSetupStatus,
|
||||
completeSetup,
|
||||
removeEndpoint,
|
||||
removeServer,
|
||||
setServerApiKey
|
||||
} from './setup/index.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
ensureSuperuserFromEnv();
|
||||
ensureDefaultsFromEnv();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -39,13 +64,46 @@ app.get('*', (req, res, next) => {
|
||||
});
|
||||
|
||||
const PORT = 4001;
|
||||
const ENDPOINT_ID = Number(process.env.PORTAINER_ENDPOINT_ID);
|
||||
|
||||
const requireActiveEndpointId = () => {
|
||||
const value = getActiveEndpointExternalId();
|
||||
if (!value) {
|
||||
const error = new Error('ENDPOINT_NOT_CONFIGURED');
|
||||
error.code = 'ENDPOINT_NOT_CONFIGURED';
|
||||
throw error;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||
|
||||
const resolvePortainerBaseUrl = () => {
|
||||
const envUrl = typeof process.env.PORTAINER_URL === 'string' ? process.env.PORTAINER_URL.trim() : '';
|
||||
if (envUrl) return envUrl;
|
||||
const activeUrl = getActiveServerUrl();
|
||||
return activeUrl ? activeUrl.trim() : '';
|
||||
};
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
httpsAgent: agent,
|
||||
headers: { "X-API-Key": process.env.PORTAINER_API_KEY },
|
||||
baseURL: process.env.PORTAINER_URL,
|
||||
httpsAgent: agent
|
||||
});
|
||||
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
const currentBase = typeof config.baseURL === 'string' ? config.baseURL.trim() : '';
|
||||
const effectiveBase = currentBase || resolvePortainerBaseUrl();
|
||||
if (!effectiveBase) {
|
||||
throw new Error('PORTAINER_URL_NOT_CONFIGURED');
|
||||
}
|
||||
config.baseURL = effectiveBase;
|
||||
|
||||
const apiKey = getActiveApiKey();
|
||||
if (apiKey) {
|
||||
config.headers = config.headers || {};
|
||||
config.headers["X-API-Key"] = apiKey;
|
||||
} else if (config.headers?.["X-API-Key"]) {
|
||||
delete config.headers["X-API-Key"];
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
const REDEPLOY_PHASES = {
|
||||
@@ -58,6 +116,132 @@ const REDEPLOY_PHASES = {
|
||||
|
||||
const redeployingStacks = new Map();
|
||||
|
||||
const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME || 'sp_auth_token';
|
||||
const DEFAULT_SESSION_TTL_MS = 1000 * 60 * 60 * 12;
|
||||
const envSessionTtl = Number(process.env.AUTH_SESSION_TTL_MS);
|
||||
const AUTH_SESSION_TTL_MS = Number.isFinite(envSessionTtl) && envSessionTtl > 0 ? envSessionTtl : DEFAULT_SESSION_TTL_MS;
|
||||
|
||||
const AUTH_COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/'
|
||||
};
|
||||
|
||||
const activeSessions = new Map();
|
||||
|
||||
const PUBLIC_API_ROUTES = [
|
||||
{ method: 'GET', matcher: /^\/api\/auth\/superuser\/status$/ },
|
||||
{ method: 'POST', matcher: /^\/api\/auth\/superuser\/register$/ },
|
||||
{ method: 'POST', matcher: /^\/api\/auth\/login$/ },
|
||||
{ method: 'POST', matcher: /^\/api\/auth\/logout$/ },
|
||||
{ method: 'GET', matcher: /^\/api\/auth\/session$/ },
|
||||
{ method: 'GET', matcher: /^\/api\/setup\/status$/ },
|
||||
{ method: 'POST', matcher: /^\/api\/setup\/complete$/ }
|
||||
];
|
||||
|
||||
const sanitizeUser = (user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email
|
||||
});
|
||||
|
||||
const cleanupExpiredSessions = () => {
|
||||
const now = Date.now();
|
||||
for (const [token, session] of activeSessions.entries()) {
|
||||
if (!session || session.expiresAt <= now) {
|
||||
activeSessions.delete(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeSessionsForUser = (userId) => {
|
||||
if (!userId) return;
|
||||
for (const [token, session] of activeSessions.entries()) {
|
||||
if (session?.userId === userId) {
|
||||
activeSessions.delete(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createSessionForUser = (user) => {
|
||||
cleanupExpiredSessions();
|
||||
removeSessionsForUser(user.id);
|
||||
const token = crypto.randomBytes(48).toString('hex');
|
||||
const expiresAt = Date.now() + AUTH_SESSION_TTL_MS;
|
||||
activeSessions.set(token, { userId: user.id, expiresAt });
|
||||
return { token, expiresAt };
|
||||
};
|
||||
|
||||
const getSessionRecord = (token) => {
|
||||
if (!token) return null;
|
||||
cleanupExpiredSessions();
|
||||
const record = activeSessions.get(token);
|
||||
if (!record) return null;
|
||||
if (record.expiresAt <= Date.now()) {
|
||||
activeSessions.delete(token);
|
||||
return null;
|
||||
}
|
||||
return record;
|
||||
};
|
||||
|
||||
const touchSession = (token) => {
|
||||
if (!token) return;
|
||||
const record = activeSessions.get(token);
|
||||
if (!record) return;
|
||||
record.expiresAt = Date.now() + AUTH_SESSION_TTL_MS;
|
||||
activeSessions.set(token, record);
|
||||
};
|
||||
|
||||
const extractAuthToken = (req) => {
|
||||
const rawCookie = req.headers?.cookie;
|
||||
if (rawCookie && typeof rawCookie === 'string') {
|
||||
const parts = rawCookie.split(';');
|
||||
for (const part of parts) {
|
||||
const [name, ...rest] = part.trim().split('=');
|
||||
if (name === AUTH_COOKIE_NAME) {
|
||||
return rest.join('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
const authHeader = req.headers?.authorization || req.headers?.Authorization;
|
||||
if (authHeader && typeof authHeader === 'string') {
|
||||
const lower = authHeader.toLowerCase();
|
||||
if (lower.startsWith('bearer ')) {
|
||||
return authHeader.slice(7).trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const setAuthCookie = (res, token) => {
|
||||
if (!token || !res) return;
|
||||
if (typeof res.cookie === 'function') {
|
||||
res.cookie(AUTH_COOKIE_NAME, token, {
|
||||
...AUTH_COOKIE_OPTIONS,
|
||||
maxAge: AUTH_SESSION_TTL_MS
|
||||
});
|
||||
} else {
|
||||
res.setHeader('Set-Cookie', `${AUTH_COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Lax`);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAuthCookie = (res) => {
|
||||
if (!res) return;
|
||||
if (typeof res.clearCookie === 'function') {
|
||||
res.clearCookie(AUTH_COOKIE_NAME, {
|
||||
...AUTH_COOKIE_OPTIONS,
|
||||
maxAge: 0
|
||||
});
|
||||
} else {
|
||||
res.setHeader('Set-Cookie', `${AUTH_COOKIE_NAME}=; Path=/; Max-Age=0`);
|
||||
}
|
||||
};
|
||||
|
||||
const isPublicApiRoute = (req) => {
|
||||
return PUBLIC_API_ROUTES.some(({ method, matcher }) => req.method === method && matcher.test(req.path));
|
||||
};
|
||||
|
||||
const isActiveRedeployPhase = (phase) => phase === REDEPLOY_PHASES.QUEUED || phase === REDEPLOY_PHASES.STARTED;
|
||||
|
||||
const resolveRedeployPhase = (phase, message) => {
|
||||
@@ -461,7 +645,8 @@ const testSshConnection = async (configOverride = null) => {
|
||||
|
||||
const detectPortainerContainer = async () => {
|
||||
try {
|
||||
const containersRes = await axiosInstance.get(`/api/endpoints/${ENDPOINT_ID}/docker/containers/json`, {
|
||||
const endpointId = requireActiveEndpointId();
|
||||
const containersRes = await axiosInstance.get(`/api/endpoints/${endpointId}/docker/containers/json`, {
|
||||
params: { all: true }
|
||||
});
|
||||
const containers = Array.isArray(containersRes.data) ? containersRes.data : [];
|
||||
@@ -487,7 +672,7 @@ const detectPortainerContainer = async () => {
|
||||
return { summary: null, error: 'Portainer-Container nicht gefunden' };
|
||||
}
|
||||
|
||||
const inspectRes = await axiosInstance.get(`/api/endpoints/${ENDPOINT_ID}/docker/containers/${matchedContainer.Id}/json`);
|
||||
const inspectRes = await axiosInstance.get(`/api/endpoints/${endpointId}/docker/containers/${matchedContainer.Id}/json`);
|
||||
const inspect = inspectRes.data ?? {};
|
||||
|
||||
const trimName = (value) => (typeof value === 'string' ? value.replace(/^\//, '') : value);
|
||||
@@ -732,6 +917,7 @@ let currentPortainerUpdatePromise = null;
|
||||
|
||||
const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) => {
|
||||
let maintenanceActivated = false;
|
||||
const endpointId = requireActiveEndpointId();
|
||||
|
||||
try {
|
||||
addUpdateLog(`Portainer-Update gestartet (Quelle: ${scriptSource})`, 'info');
|
||||
@@ -758,7 +944,7 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
||||
stackName: 'Portainer',
|
||||
status: 'started',
|
||||
message: `Portainer Update gestartet (Ziel: ${targetVersion ?? 'unbekannt'})`,
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
});
|
||||
|
||||
@@ -767,7 +953,7 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
||||
stackName: 'StackPulse Wartung',
|
||||
status: 'started',
|
||||
message: 'Wartungsmodus aktiviert (Portainer Update)',
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
});
|
||||
|
||||
@@ -797,7 +983,7 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
||||
stackName: 'Portainer',
|
||||
status: 'success',
|
||||
message: `Portainer Update abgeschlossen (Version: ${finalVersion ?? 'unbekannt'})`,
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
});
|
||||
|
||||
@@ -821,7 +1007,7 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
||||
stackName: 'StackPulse Wartung',
|
||||
status: 'success',
|
||||
message: 'Wartungsmodus deaktiviert',
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
});
|
||||
}
|
||||
@@ -832,7 +1018,7 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
||||
stackName: 'Portainer',
|
||||
status: 'error',
|
||||
message,
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
});
|
||||
|
||||
@@ -854,7 +1040,7 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
||||
stackName: 'StackPulse Wartung',
|
||||
status: 'error',
|
||||
message: 'Wartungsmodus deaktiviert (Fehler)',
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
});
|
||||
}
|
||||
@@ -864,8 +1050,9 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
||||
};
|
||||
|
||||
const fetchPortainerStacks = async () => {
|
||||
const endpointId = requireActiveEndpointId();
|
||||
const stacksRes = await axiosInstance.get('/api/stacks');
|
||||
return stacksRes.data.filter((stack) => stack.EndpointId === ENDPOINT_ID);
|
||||
return stacksRes.data.filter((stack) => String(stack.EndpointId) === String(endpointId));
|
||||
};
|
||||
|
||||
const buildStackCollections = (stacks = []) => {
|
||||
@@ -982,13 +1169,14 @@ const shouldFallbackToStackFile = (message) => {
|
||||
const redeployStackById = async (stackId, redeployType) => {
|
||||
let stack;
|
||||
const messages = getRedeployMessages(redeployType);
|
||||
const endpointId = requireActiveEndpointId();
|
||||
|
||||
try {
|
||||
const stackRes = await axiosInstance.get(`/api/stacks/${stackId}`);
|
||||
stack = stackRes.data;
|
||||
|
||||
if (stack.EndpointId !== ENDPOINT_ID) {
|
||||
throw new Error(`Stack gehört nicht zum Endpoint ${ENDPOINT_ID}`);
|
||||
if (String(stack.EndpointId) !== String(endpointId)) {
|
||||
throw new Error(`Stack gehört nicht zum Endpoint ${endpointId}`);
|
||||
}
|
||||
|
||||
const targetId = stack.Id || stackId;
|
||||
@@ -1107,7 +1295,7 @@ const redeployStackById = async (stackId, redeployType) => {
|
||||
stackName: fallbackName,
|
||||
status: 'error',
|
||||
message: errorMessage,
|
||||
endpoint: stack?.EndpointId || ENDPOINT_ID,
|
||||
endpoint: stack?.EndpointId || endpointId,
|
||||
redeployType
|
||||
});
|
||||
|
||||
@@ -1116,8 +1304,373 @@ const redeployStackById = async (stackId, redeployType) => {
|
||||
}
|
||||
};
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (!req.path.startsWith('/api')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS' || isPublicApiRoute(req)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!hasSuperuser()) {
|
||||
return res.status(403).json({ error: 'SUPERUSER_REQUIRED' });
|
||||
}
|
||||
|
||||
if (!hasServer() || !hasEndpoint() || !hasApiKey()) {
|
||||
return res.status(403).json({ error: 'SETUP_REQUIRED' });
|
||||
}
|
||||
|
||||
const token = extractAuthToken(req);
|
||||
const session = getSessionRecord(token);
|
||||
if (!session) {
|
||||
clearAuthCookie(res);
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
const user = findUserById(session.userId);
|
||||
if (!user || !user.is_active) {
|
||||
activeSessions.delete(token);
|
||||
clearAuthCookie(res);
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
req.user = sanitizeUser(user);
|
||||
req.authToken = token;
|
||||
touchSession(token);
|
||||
setAuthCookie(res, token);
|
||||
next();
|
||||
});
|
||||
|
||||
// --- API Endpoints ---
|
||||
|
||||
app.get('/api/setup/status', (req, res) => {
|
||||
try {
|
||||
const status = getSetupStatus();
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
console.error('⚠️ [Setup] Statusabfrage fehlgeschlagen:', error);
|
||||
res.status(500).json({ error: 'SETUP_STATUS_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/setup/complete', (req, res) => {
|
||||
const { superuser: superuserInput, server: serverInput, endpoint: endpointInput, apiKey: apiKeyInput } = req.body ?? {};
|
||||
const initialStatus = getSetupStatus();
|
||||
const needsSuperuser = initialStatus.requirements.superuser;
|
||||
const needsServer = initialStatus.requirements.server;
|
||||
const needsEndpoint = initialStatus.requirements.endpoint;
|
||||
const needsApiKey = initialStatus.requirements.apiKey;
|
||||
|
||||
const created = {};
|
||||
|
||||
const normalizeServerInput = (input) => {
|
||||
if (!input) return null;
|
||||
const name = typeof input.name === 'string' ? input.name.trim() : '';
|
||||
const url = typeof input.url === 'string' ? input.url.trim() : '';
|
||||
if (!url) {
|
||||
return { name: name || '', url: '' };
|
||||
}
|
||||
return {
|
||||
name: name || null,
|
||||
url
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeEndpointInput = (input) => {
|
||||
if (!input) return null;
|
||||
const name = typeof input.name === 'string' ? input.name.trim() : '';
|
||||
const externalRaw = input.externalId !== undefined && input.externalId !== null
|
||||
? String(input.externalId).trim()
|
||||
: '';
|
||||
const serverIdRaw = input.serverId !== undefined && input.serverId !== null ? Number(input.serverId) : null;
|
||||
const serverId = Number.isFinite(serverIdRaw) ? serverIdRaw : null;
|
||||
|
||||
return {
|
||||
name: name || null,
|
||||
externalId: externalRaw || '',
|
||||
serverId
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeApiKeyInput = (input) => {
|
||||
if (!input) return null;
|
||||
if (typeof input === 'string') {
|
||||
const value = input.trim();
|
||||
return { value, serverId: null };
|
||||
}
|
||||
if (typeof input === 'object') {
|
||||
const rawValue = typeof input.value === 'string' ? input.value : typeof input.key === 'string' ? input.key : '';
|
||||
const value = rawValue.trim();
|
||||
const serverIdRaw = input.serverId !== undefined && input.serverId !== null ? Number(input.serverId) : null;
|
||||
const serverId = Number.isFinite(serverIdRaw) ? serverIdRaw : null;
|
||||
return { value, serverId };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
let serverPayload = normalizeServerInput(serverInput);
|
||||
let endpointPayload = normalizeEndpointInput(endpointInput);
|
||||
const apiKeyPayload = normalizeApiKeyInput(apiKeyInput);
|
||||
|
||||
if (needsServer && (!serverPayload || !serverPayload.url)) {
|
||||
return res.status(400).json({ error: 'SERVER_DETAILS_REQUIRED' });
|
||||
}
|
||||
|
||||
if (endpointPayload && !endpointPayload.externalId) {
|
||||
endpointPayload.externalId = '';
|
||||
}
|
||||
|
||||
if (needsEndpoint && (!endpointPayload || !endpointPayload.externalId)) {
|
||||
const fallbackExternal = initialStatus.envDefaults.endpointExternalId?.trim();
|
||||
if (fallbackExternal) {
|
||||
endpointPayload = endpointPayload || {};
|
||||
endpointPayload.externalId = fallbackExternal;
|
||||
endpointPayload.name = endpointPayload.name || initialStatus.envDefaults.endpointName || `Endpoint ${fallbackExternal}`;
|
||||
} else {
|
||||
return res.status(400).json({ error: 'ENDPOINT_DETAILS_REQUIRED' });
|
||||
}
|
||||
}
|
||||
|
||||
const shouldHandleInfrastructure =
|
||||
needsServer ||
|
||||
needsEndpoint ||
|
||||
(serverPayload && serverPayload.url) ||
|
||||
(endpointPayload && endpointPayload.externalId);
|
||||
|
||||
if (shouldHandleInfrastructure) {
|
||||
const endpointInputPayload = endpointPayload;
|
||||
if (!endpointInputPayload || !endpointInputPayload.externalId) {
|
||||
return res.status(400).json({ error: 'ENDPOINT_DETAILS_REQUIRED' });
|
||||
}
|
||||
|
||||
const setupResult = completeSetup({
|
||||
server: serverPayload && serverPayload.url ? serverPayload : null,
|
||||
endpoint: endpointInputPayload
|
||||
});
|
||||
|
||||
created.server = setupResult.server;
|
||||
created.endpoint = setupResult.endpoint;
|
||||
created.defaultEndpoint = setupResult.defaultEndpoint;
|
||||
}
|
||||
|
||||
let targetServerId = apiKeyPayload?.serverId ?? created.server?.id ?? endpointPayload?.serverId ?? null;
|
||||
|
||||
if (needsSuperuser) {
|
||||
const username = typeof superuserInput?.username === 'string' ? superuserInput.username.trim() : '';
|
||||
const email = typeof superuserInput?.email === 'string' ? superuserInput.email.trim() : '';
|
||||
const password = typeof superuserInput?.password === 'string' ? superuserInput.password : '';
|
||||
|
||||
if (!username || !email || !password) {
|
||||
return res.status(400).json({ error: 'SUPERUSER_DETAILS_REQUIRED' });
|
||||
}
|
||||
|
||||
const user = registerSuperuser({ username, email, password });
|
||||
created.superuser = user;
|
||||
}
|
||||
|
||||
const statusSnapshot = getSetupStatus();
|
||||
|
||||
if (!targetServerId) {
|
||||
targetServerId = statusSnapshot.servers.items?.[0]?.id ?? null;
|
||||
}
|
||||
|
||||
if (apiKeyPayload && apiKeyPayload.value) {
|
||||
if (!targetServerId) {
|
||||
return res.status(400).json({ error: 'SERVER_DETAILS_REQUIRED' });
|
||||
}
|
||||
const apiKeyResult = setServerApiKey({ serverId: targetServerId, apiKey: apiKeyPayload.value });
|
||||
created.apiKey = apiKeyResult;
|
||||
} else if (needsApiKey) {
|
||||
return res.status(400).json({ error: 'API_KEY_REQUIRED' });
|
||||
}
|
||||
|
||||
const finalStatus = getSetupStatus();
|
||||
created.defaultEndpoint = finalStatus.endpoints.default;
|
||||
res.status(201).json({
|
||||
success: finalStatus.setupComplete,
|
||||
status: finalStatus,
|
||||
created
|
||||
});
|
||||
} catch (error) {
|
||||
const code = error.code || error.message;
|
||||
switch (code) {
|
||||
case 'SERVER_URL_REQUIRED':
|
||||
case 'SERVER_NAME_REQUIRED':
|
||||
case 'SERVER_DETAILS_REQUIRED':
|
||||
case 'ENDPOINT_DETAILS_REQUIRED':
|
||||
case 'ENDPOINT_EXTERNAL_ID_REQUIRED':
|
||||
case 'USERNAME_REQUIRED':
|
||||
case 'EMAIL_INVALID':
|
||||
case 'PASSWORD_TOO_SHORT':
|
||||
case 'INVALID_PASSWORD':
|
||||
case 'API_KEY_REQUIRED':
|
||||
return res.status(400).json({ error: code });
|
||||
case 'API_KEY_ENCRYPT_FAILED':
|
||||
console.error('⚠️ [Setup] API-Key Verschlüsselung fehlgeschlagen:', error);
|
||||
return res.status(500).json({ error: 'API_KEY_ENCRYPT_FAILED' });
|
||||
case 'SERVER_NOT_FOUND':
|
||||
return res.status(404).json({ error: 'SERVER_NOT_FOUND' });
|
||||
case 'SUPERUSER_ALREADY_EXISTS':
|
||||
return res.status(409).json({ error: 'SUPERUSER_EXISTS' });
|
||||
default:
|
||||
console.error('⚠️ [Setup] Fehler beim Abschluss:', error);
|
||||
return res.status(500).json({ error: 'SETUP_FAILED' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/setup/endpoints/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const numericId = Number(id);
|
||||
if (!Number.isFinite(numericId)) {
|
||||
return res.status(400).json({ error: 'ENDPOINT_ID_INVALID' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = removeEndpoint(numericId);
|
||||
const status = getSetupStatus();
|
||||
res.json({ success: true, removed: result, status });
|
||||
} catch (error) {
|
||||
const code = error.code || error.message;
|
||||
switch (code) {
|
||||
case 'ENDPOINT_ID_INVALID':
|
||||
return res.status(400).json({ error: 'ENDPOINT_ID_INVALID' });
|
||||
case 'ENDPOINT_NOT_FOUND':
|
||||
return res.status(404).json({ error: 'ENDPOINT_NOT_FOUND' });
|
||||
default:
|
||||
console.error('⚠️ [Setup] Endpoint konnte nicht gelöscht werden:', error);
|
||||
return res.status(500).json({ error: 'ENDPOINT_DELETE_FAILED' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/setup/servers/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const numericId = Number(id);
|
||||
if (!Number.isFinite(numericId)) {
|
||||
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = removeServer(numericId);
|
||||
const status = getSetupStatus();
|
||||
res.json({ success: true, removed: result, status });
|
||||
} catch (error) {
|
||||
const code = error.code || error.message;
|
||||
switch (code) {
|
||||
case 'SERVER_ID_INVALID':
|
||||
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
|
||||
case 'SERVER_NOT_FOUND':
|
||||
return res.status(404).json({ error: 'SERVER_NOT_FOUND' });
|
||||
default:
|
||||
console.error('⚠️ [Setup] Server konnte nicht gelöscht werden:', error);
|
||||
return res.status(500).json({ error: 'SERVER_DELETE_FAILED' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/setup/servers/:id/api-key', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const numericId = Number(id);
|
||||
if (!Number.isFinite(numericId)) {
|
||||
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
|
||||
}
|
||||
|
||||
const rawValue = typeof req.body?.apiKey === 'string' ? req.body.apiKey : typeof req.body?.key === 'string' ? req.body.key : '';
|
||||
|
||||
try {
|
||||
const result = setServerApiKey({ serverId: numericId, apiKey: rawValue });
|
||||
const status = getSetupStatus();
|
||||
res.json({ success: true, updated: result, status });
|
||||
} catch (error) {
|
||||
const code = error.code || error.message;
|
||||
switch (code) {
|
||||
case 'SERVER_ID_INVALID':
|
||||
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
|
||||
case 'SERVER_NOT_FOUND':
|
||||
return res.status(404).json({ error: 'SERVER_NOT_FOUND' });
|
||||
case 'API_KEY_REQUIRED':
|
||||
return res.status(400).json({ error: 'API_KEY_REQUIRED' });
|
||||
case 'API_KEY_ENCRYPT_FAILED':
|
||||
console.error('⚠️ [Setup] API-Key Verschlüsselung fehlgeschlagen:', error);
|
||||
return res.status(500).json({ error: 'API_KEY_ENCRYPT_FAILED' });
|
||||
default:
|
||||
console.error('⚠️ [Setup] API-Key konnte nicht aktualisiert werden:', error);
|
||||
return res.status(500).json({ error: 'API_KEY_UPDATE_FAILED' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', (req, res) => {
|
||||
if (!hasSuperuser()) {
|
||||
return res.status(403).json({ error: 'SUPERUSER_REQUIRED' });
|
||||
}
|
||||
|
||||
if (!hasServer() || !hasEndpoint() || !hasApiKey()) {
|
||||
return res.status(403).json({ error: 'SETUP_REQUIRED' });
|
||||
}
|
||||
|
||||
const { identifier, password } = req.body ?? {};
|
||||
if (!identifier || !password) {
|
||||
return res.status(400).json({ error: 'MISSING_CREDENTIALS' });
|
||||
}
|
||||
|
||||
const user = findUserByIdentifier(identifier);
|
||||
if (!user || !user.is_active) {
|
||||
return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
|
||||
}
|
||||
|
||||
const passwordValid = verifyPassword(password, user.password_hash, user.password_salt);
|
||||
if (!passwordValid) {
|
||||
return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
|
||||
}
|
||||
|
||||
const session = createSessionForUser(user);
|
||||
markUserLogin(user.id);
|
||||
setAuthCookie(res, session.token);
|
||||
|
||||
res.json({ user: sanitizeUser(user) });
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
const token = extractAuthToken(req);
|
||||
if (token) {
|
||||
activeSessions.delete(token);
|
||||
}
|
||||
clearAuthCookie(res);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/auth/session', (req, res) => {
|
||||
if (!hasSuperuser()) {
|
||||
return res.status(403).json({ error: 'SUPERUSER_REQUIRED' });
|
||||
}
|
||||
|
||||
if (!hasServer() || !hasEndpoint() || !hasApiKey()) {
|
||||
return res.status(403).json({ error: 'SETUP_REQUIRED' });
|
||||
}
|
||||
|
||||
const token = extractAuthToken(req);
|
||||
const session = getSessionRecord(token);
|
||||
if (!session) {
|
||||
clearAuthCookie(res);
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
const user = findUserById(session.userId);
|
||||
if (!user || !user.is_active) {
|
||||
activeSessions.delete(token);
|
||||
clearAuthCookie(res);
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
touchSession(token);
|
||||
setAuthCookie(res, token);
|
||||
res.json({ user: sanitizeUser(user) });
|
||||
});
|
||||
|
||||
// Superuser Setup
|
||||
app.get('/api/auth/superuser/status', (req, res) => {
|
||||
const exists = hasSuperuser();
|
||||
@@ -1225,6 +1778,9 @@ app.get('/api/stacks', maintenanceGuard, async (req, res) => {
|
||||
res.json(stacksWithStatus);
|
||||
} catch (err) {
|
||||
console.error(`❌ Fehler beim Abrufen der Stacks:`, err.message);
|
||||
if (err.message === 'PORTAINER_URL_NOT_CONFIGURED') {
|
||||
return res.status(503).json({ error: 'PORTAINER_URL_NOT_CONFIGURED' });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
@@ -1237,6 +1793,9 @@ app.get('/api/maintenance/portainer-status', async (req, res) => {
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.message || err.message || 'Unbekannter Fehler';
|
||||
console.error(`❌ [Maintenance] Fehler beim Prüfen des Portainer-Status: ${message}`);
|
||||
if (message === 'PORTAINER_URL_NOT_CONFIGURED') {
|
||||
return res.status(503).json({ error: 'PORTAINER_URL_NOT_CONFIGURED' });
|
||||
}
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
@@ -1737,8 +2296,9 @@ app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
|
||||
console.log(`🚀 PUT /api/stacks/redeploy-all: Redeploy ALL gestartet`);
|
||||
|
||||
try {
|
||||
const endpointId = requireActiveEndpointId();
|
||||
const stacksRes = await axiosInstance.get('/api/stacks');
|
||||
const filteredStacks = stacksRes.data.filter(stack => stack.EndpointId === ENDPOINT_ID);
|
||||
const filteredStacks = stacksRes.data.filter(stack => String(stack.EndpointId) === String(endpointId));
|
||||
|
||||
console.log("📦 Redeploy ALL für folgende Stacks:");
|
||||
filteredStacks.forEach(s => console.log(` - ${s.Name}`));
|
||||
@@ -1758,7 +2318,7 @@ app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
|
||||
stackName: '---',
|
||||
status: 'started',
|
||||
message: `Redeploy ALL gestartet für: ${stackSummary}`,
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.ALL
|
||||
});
|
||||
|
||||
@@ -1789,7 +2349,7 @@ app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
|
||||
stackName: '---',
|
||||
status: 'success',
|
||||
message: 'Redeploy ALL abgeschlossen',
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.ALL
|
||||
});
|
||||
|
||||
@@ -1801,7 +2361,7 @@ app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
|
||||
stackName: '---',
|
||||
status: 'error',
|
||||
message,
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.ALL
|
||||
});
|
||||
console.error('❌ Fehler bei Redeploy ALL:', message);
|
||||
@@ -1820,6 +2380,7 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) =>
|
||||
}
|
||||
|
||||
try {
|
||||
const endpointId = requireActiveEndpointId();
|
||||
const normalizedIds = stackIds.map((id) => String(id));
|
||||
const { filteredStacks } = await loadStackCollections();
|
||||
const stacksById = new Map(filteredStacks.map((stack) => [String(stack.Id), stack]));
|
||||
@@ -1850,7 +2411,7 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) =>
|
||||
stackName: `Auswahl (${stackIds.length})`,
|
||||
status: 'started',
|
||||
message: `Redeploy Auswahl gestartet für: ${summaryText}`,
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.SELECTION
|
||||
});
|
||||
|
||||
@@ -1860,7 +2421,7 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) =>
|
||||
stackName: `Auswahl (${stackIds.length})`,
|
||||
status: 'success',
|
||||
message: 'Redeploy Auswahl übersprungen: keine veralteten Stacks',
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.SELECTION
|
||||
});
|
||||
return res.json({ success: true, message: 'Keine veralteten Stacks in der Auswahl' });
|
||||
@@ -1888,7 +2449,7 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) =>
|
||||
stackName: `Auswahl (${stackIds.length})`,
|
||||
status: 'success',
|
||||
message: 'Redeploy Auswahl abgeschlossen',
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.SELECTION
|
||||
});
|
||||
|
||||
@@ -1900,7 +2461,7 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) =>
|
||||
stackName: `Auswahl (${Array.isArray(stackIds) ? stackIds.length : 0})`,
|
||||
status: 'error',
|
||||
message,
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.SELECTION
|
||||
});
|
||||
console.error('❌ Fehler bei Redeploy Auswahl:', message);
|
||||
@@ -1911,4 +2472,3 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) =>
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🚀 Server läuft auf Port ${PORT}`);
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -32,8 +32,8 @@
|
||||
<!-- Nepcha Analytics (nepcha.com) -->
|
||||
<!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. -->
|
||||
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-61a55789.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-8ee0592b.css">
|
||||
<script type="module" crossorigin src="/assets/index-999a8a11.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-4350b70a.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
import crypto from 'crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { hasSuperuser } from '../auth/superuser.js';
|
||||
|
||||
const selectAllServers = db.prepare('SELECT * FROM servers ORDER BY id ASC');
|
||||
const selectServerById = db.prepare('SELECT * FROM servers WHERE id = ?');
|
||||
const selectServerByUrl = db.prepare('SELECT * FROM servers WHERE url = ?');
|
||||
const insertServer = db.prepare(`
|
||||
INSERT INTO servers (name, url)
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
const updateServer = db.prepare(`
|
||||
UPDATE servers
|
||||
SET name = ?, url = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const selectAllEndpoints = db.prepare(`
|
||||
SELECT e.*, s.name as server_name, s.url as server_url
|
||||
FROM endpoints e
|
||||
INNER JOIN servers s ON s.id = e.server_id
|
||||
ORDER BY e.id ASC
|
||||
`);
|
||||
const selectEndpointById = db.prepare('SELECT * FROM endpoints WHERE id = ?');
|
||||
const selectEndpointByExternalId = db.prepare('SELECT * FROM endpoints WHERE external_id = ?');
|
||||
const selectEndpointByServerAndExternal = db.prepare('SELECT * FROM endpoints WHERE server_id = ? AND external_id = ?');
|
||||
const selectEndpointsByServerId = db.prepare('SELECT * FROM endpoints WHERE server_id = ?');
|
||||
const insertEndpoint = db.prepare(`
|
||||
INSERT INTO endpoints (server_id, name, external_id, is_default)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
const updateEndpoint = db.prepare(`
|
||||
UPDATE endpoints
|
||||
SET name = ?, external_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
const deleteEndpointStmt = db.prepare('DELETE FROM endpoints WHERE id = ?');
|
||||
const deleteServerStmt = db.prepare('DELETE FROM servers WHERE id = ?');
|
||||
|
||||
const selectApiKeyByServerId = db.prepare('SELECT * FROM server_api_keys WHERE server_id = ?');
|
||||
const countApiKeysStmt = db.prepare('SELECT COUNT(*) as count FROM server_api_keys');
|
||||
const selectAllApiKeys = db.prepare('SELECT server_id, created_at, updated_at FROM server_api_keys');
|
||||
const upsertApiKey = db.prepare(`
|
||||
INSERT INTO server_api_keys (server_id, key_cipher, key_iv, key_tag, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(server_id) DO UPDATE SET
|
||||
key_cipher = excluded.key_cipher,
|
||||
key_iv = excluded.key_iv,
|
||||
key_tag = excluded.key_tag,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`);
|
||||
const deleteApiKeyByServerId = db.prepare('DELETE FROM server_api_keys WHERE server_id = ?');
|
||||
|
||||
const clearDefaultEndpointStmt = db.prepare('UPDATE endpoints SET is_default = 0 WHERE is_default != 0');
|
||||
const setDefaultEndpointStmt = db.prepare('UPDATE endpoints SET is_default = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?');
|
||||
const selectDefaultEndpointStmt = db.prepare(`
|
||||
SELECT e.*, s.name as server_name, s.url as server_url
|
||||
FROM endpoints e
|
||||
INNER JOIN servers s ON s.id = e.server_id
|
||||
WHERE e.is_default = 1
|
||||
LIMIT 1
|
||||
`);
|
||||
const countServersStmt = db.prepare('SELECT COUNT(*) as count FROM servers');
|
||||
const countEndpointsStmt = db.prepare('SELECT COUNT(*) as count FROM endpoints');
|
||||
const selectFirstServerStmt = db.prepare('SELECT * FROM servers ORDER BY id ASC LIMIT 1');
|
||||
const selectFirstEndpointStmt = db.prepare(`
|
||||
SELECT e.*, s.name as server_name, s.url as server_url
|
||||
FROM endpoints e
|
||||
INNER JOIN servers s ON s.id = e.server_id
|
||||
ORDER BY e.id ASC
|
||||
LIMIT 1
|
||||
`);
|
||||
const transactionalSetDefaultEndpoint = db.transaction((endpointId) => {
|
||||
clearDefaultEndpointStmt.run();
|
||||
setDefaultEndpointStmt.run(endpointId);
|
||||
});
|
||||
|
||||
const API_KEY_SECRET = crypto.createHash('sha256')
|
||||
.update(process.env.PORTAINER_API_SECRET || process.env.PORTAINER_API_KEY || 'stackpulse-portainer-api-key')
|
||||
.digest();
|
||||
|
||||
function encryptApiKey(apiKey) {
|
||||
if (!apiKey || typeof apiKey !== 'string') return null;
|
||||
const trimmed = apiKey.trim();
|
||||
if (!trimmed) return null;
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', API_KEY_SECRET, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(trimmed, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return {
|
||||
iv: iv.toString('base64'),
|
||||
content: encrypted.toString('base64'),
|
||||
tag: tag.toString('base64')
|
||||
};
|
||||
}
|
||||
|
||||
function decryptApiKey(row) {
|
||||
if (!row) return '';
|
||||
try {
|
||||
const iv = Buffer.from(row.key_iv, 'base64');
|
||||
const content = Buffer.from(row.key_cipher, 'base64');
|
||||
const tag = Buffer.from(row.key_tag, 'base64');
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', API_KEY_SECRET, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const decrypted = Buffer.concat([decipher.update(content), decipher.final()]);
|
||||
return decrypted.toString('utf8');
|
||||
} catch (error) {
|
||||
console.warn('⚠️ [Setup] API-Key konnte nicht entschlüsselt werden:', error.message);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(url) {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const hasProtocol = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed);
|
||||
const candidate = hasProtocol ? trimmed : `https://${trimmed}`;
|
||||
const normalized = new URL(candidate);
|
||||
const pathname = normalized.pathname.replace(/\/+$/, '');
|
||||
normalized.pathname = pathname || '/';
|
||||
normalized.hash = '';
|
||||
return normalized.toString().replace(/\/$/, '');
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function deriveServerName(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname || url;
|
||||
} catch (err) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureServer({ name, url }) {
|
||||
const normalizedUrl = normalizeUrl(url);
|
||||
if (!normalizedUrl) {
|
||||
const error = new Error('SERVER_URL_REQUIRED');
|
||||
error.code = 'SERVER_URL_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
const normalizedName = (name || deriveServerName(normalizedUrl)).trim();
|
||||
if (!normalizedName) {
|
||||
const error = new Error('SERVER_NAME_REQUIRED');
|
||||
error.code = 'SERVER_NAME_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existing = selectServerByUrl.get(normalizedUrl);
|
||||
if (existing) {
|
||||
if (existing.name !== normalizedName) {
|
||||
updateServer.run(normalizedName, normalizedUrl, existing.id);
|
||||
const updated = selectServerById.get(existing.id);
|
||||
console.log(`ℹ️ [Setup] Server aktualisiert: ${updated.name} (${updated.id})`);
|
||||
return updated;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const result = insertServer.run(normalizedName, normalizedUrl);
|
||||
const created = selectServerById.get(result.lastInsertRowid);
|
||||
console.log(`✅ [Setup] Server angelegt: ${created.name} (${created.id})`);
|
||||
return created;
|
||||
}
|
||||
|
||||
function ensureEndpoint({ serverId, name, externalId, makeDefault = false }) {
|
||||
if (!serverId) {
|
||||
const error = new Error('SERVER_REQUIRED');
|
||||
error.code = 'SERVER_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
const server = selectServerById.get(serverId);
|
||||
if (!server) {
|
||||
const error = new Error('SERVER_NOT_FOUND');
|
||||
error.code = 'SERVER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const trimmedExternal = String(externalId ?? '').trim();
|
||||
if (!trimmedExternal) {
|
||||
const error = new Error('ENDPOINT_EXTERNAL_ID_REQUIRED');
|
||||
error.code = 'ENDPOINT_EXTERNAL_ID_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedName = String(name || `Endpoint ${trimmedExternal}`).trim();
|
||||
if (!normalizedName) {
|
||||
const error = new Error('ENDPOINT_NAME_REQUIRED');
|
||||
error.code = 'ENDPOINT_NAME_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
let existing = selectEndpointByServerAndExternal.get(serverId, trimmedExternal);
|
||||
if (existing) {
|
||||
if (existing.name !== normalizedName || existing.external_id !== trimmedExternal) {
|
||||
updateEndpoint.run(normalizedName, trimmedExternal, existing.id);
|
||||
existing = selectEndpointById.get(existing.id);
|
||||
console.log(`ℹ️ [Setup] Endpoint aktualisiert: ${existing.name} (${existing.id}) für Server ${server.name} (${server.id})`);
|
||||
}
|
||||
} else {
|
||||
const isDefault = makeDefault || countEndpointsStmt.get().count === 0 ? 1 : 0;
|
||||
const result = insertEndpoint.run(serverId, normalizedName, trimmedExternal, isDefault);
|
||||
existing = selectEndpointById.get(result.lastInsertRowid);
|
||||
console.log(`✅ [Setup] Endpoint angelegt: ${existing.name} (${existing.id}) für Server ${server.name} (${server.id})`);
|
||||
}
|
||||
|
||||
if (makeDefault && existing && !existing.is_default) {
|
||||
transactionalSetDefaultEndpoint(existing.id);
|
||||
existing = selectEndpointById.get(existing.id);
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
function removeEndpoint(endpointId) {
|
||||
const id = Number(endpointId);
|
||||
if (!Number.isFinite(id)) {
|
||||
const error = new Error('ENDPOINT_ID_INVALID');
|
||||
error.code = 'ENDPOINT_ID_INVALID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const endpoint = selectEndpointById.get(id);
|
||||
if (!endpoint) {
|
||||
const error = new Error('ENDPOINT_NOT_FOUND');
|
||||
error.code = 'ENDPOINT_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const wasDefault = Boolean(endpoint.is_default);
|
||||
deleteEndpointStmt.run(id);
|
||||
|
||||
if (wasDefault) {
|
||||
const fallback = selectFirstEndpointStmt.get();
|
||||
if (fallback) {
|
||||
transactionalSetDefaultEndpoint(fallback.id);
|
||||
console.log(`ℹ️ [Setup] Neuer Standard-Endpoint gesetzt: ${fallback.name} (${fallback.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🗑️ [Setup] Endpoint entfernt: ${endpoint.name} (${endpoint.id}) für Server ${endpoint.server_id}`);
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
removed: true
|
||||
};
|
||||
}
|
||||
|
||||
function setServerApiKey({ serverId, apiKey }) {
|
||||
const id = Number(serverId);
|
||||
if (!Number.isFinite(id)) {
|
||||
const error = new Error('SERVER_ID_INVALID');
|
||||
error.code = 'SERVER_ID_INVALID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const server = selectServerById.get(id);
|
||||
if (!server) {
|
||||
const error = new Error('SERVER_NOT_FOUND');
|
||||
error.code = 'SERVER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedKey = typeof apiKey === 'string' ? apiKey.trim() : '';
|
||||
if (!normalizedKey) {
|
||||
const error = new Error('API_KEY_REQUIRED');
|
||||
error.code = 'API_KEY_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const encrypted = encryptApiKey(normalizedKey);
|
||||
if (!encrypted) {
|
||||
const error = new Error('API_KEY_ENCRYPT_FAILED');
|
||||
error.code = 'API_KEY_ENCRYPT_FAILED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existing = selectApiKeyByServerId.get(id);
|
||||
upsertApiKey.run(id, encrypted.content, encrypted.iv, encrypted.tag);
|
||||
console.log(`${existing ? 'ℹ️' : '🔐'} [Setup] API-Key ${existing ? 'aktualisiert' : 'angelegt'}: Server ${server.name} (${server.id})`);
|
||||
|
||||
return {
|
||||
serverId: server.id,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function setDefaultEndpoint(endpointId) {
|
||||
if (!endpointId) return null;
|
||||
const endpoint = selectEndpointById.get(endpointId);
|
||||
if (!endpoint) {
|
||||
const error = new Error('ENDPOINT_NOT_FOUND');
|
||||
error.code = 'ENDPOINT_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
transactionalSetDefaultEndpoint(endpointId);
|
||||
return selectEndpointById.get(endpointId);
|
||||
}
|
||||
|
||||
function getDefaultEndpoint() {
|
||||
let endpoint = selectDefaultEndpointStmt.get();
|
||||
if (!endpoint) {
|
||||
endpoint = selectFirstEndpointStmt.get();
|
||||
if (endpoint) {
|
||||
transactionalSetDefaultEndpoint(endpoint.id);
|
||||
endpoint = selectDefaultEndpointStmt.get();
|
||||
}
|
||||
}
|
||||
return endpoint || null;
|
||||
}
|
||||
|
||||
function getActiveEndpointExternalId() {
|
||||
const endpoint = getDefaultEndpoint();
|
||||
return endpoint ? endpoint.external_id : null;
|
||||
}
|
||||
|
||||
function getActiveApiKey() {
|
||||
const defaultEndpoint = getDefaultEndpoint();
|
||||
if (defaultEndpoint) {
|
||||
const row = selectApiKeyByServerId.get(defaultEndpoint.server_id);
|
||||
const key = decryptApiKey(row);
|
||||
if (key) return key;
|
||||
}
|
||||
|
||||
const firstServer = selectFirstServerStmt.get();
|
||||
if (firstServer) {
|
||||
const row = selectApiKeyByServerId.get(firstServer.id);
|
||||
const key = decryptApiKey(row);
|
||||
if (key) return key;
|
||||
}
|
||||
|
||||
const envKey = process.env.PORTAINER_API_KEY ? process.env.PORTAINER_API_KEY.trim() : '';
|
||||
return envKey || '';
|
||||
}
|
||||
|
||||
function getActiveServerUrl() {
|
||||
const endpoint = getDefaultEndpoint();
|
||||
if (endpoint) {
|
||||
const normalizedEndpointUrl = endpoint.server_url ? normalizeUrl(endpoint.server_url) : '';
|
||||
if (normalizedEndpointUrl) {
|
||||
return normalizedEndpointUrl;
|
||||
}
|
||||
const server = selectServerById.get(endpoint.server_id);
|
||||
if (server?.url) {
|
||||
const normalizedServerUrl = normalizeUrl(server.url);
|
||||
if (normalizedServerUrl) {
|
||||
return normalizedServerUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const firstServer = selectFirstServerStmt.get();
|
||||
if (firstServer?.url) {
|
||||
const normalizedUrl = normalizeUrl(firstServer.url);
|
||||
if (normalizedUrl) {
|
||||
return normalizedUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const envUrl = process.env.PORTAINER_URL ? process.env.PORTAINER_URL.trim() : '';
|
||||
const normalizedEnvUrl = envUrl ? normalizeUrl(envUrl) : '';
|
||||
return normalizedEnvUrl || '';
|
||||
}
|
||||
|
||||
function hasServer() {
|
||||
const { count } = countServersStmt.get();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
function removeServer(serverId) {
|
||||
const id = Number(serverId);
|
||||
if (!Number.isFinite(id)) {
|
||||
const error = new Error('SERVER_ID_INVALID');
|
||||
error.code = 'SERVER_ID_INVALID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const server = selectServerById.get(id);
|
||||
if (!server) {
|
||||
const error = new Error('SERVER_NOT_FOUND');
|
||||
error.code = 'SERVER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const relatedEndpoints = selectEndpointsByServerId.all(id);
|
||||
const hadDefaultEndpoint = relatedEndpoints.some((endpoint) => endpoint.is_default);
|
||||
const existingApiKey = selectApiKeyByServerId.get(id);
|
||||
|
||||
deleteServerStmt.run(id);
|
||||
|
||||
if (existingApiKey) {
|
||||
console.log(`🗑️ [Setup] API-Key entfernt: Server ${server.name} (${server.id})`);
|
||||
}
|
||||
|
||||
if (hadDefaultEndpoint) {
|
||||
const fallback = getDefaultEndpoint();
|
||||
if (fallback) {
|
||||
console.log(`ℹ️ [Setup] Neuer Standard-Endpoint gesetzt: ${fallback.name} (${fallback.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🗑️ [Setup] Server entfernt: ${server.name} (${server.id}) – entfernte Endpoints: ${relatedEndpoints.length}`);
|
||||
|
||||
return {
|
||||
server,
|
||||
removed: true,
|
||||
endpointsRemoved: relatedEndpoints.length
|
||||
};
|
||||
}
|
||||
|
||||
function hasEndpoint() {
|
||||
const { count } = countEndpointsStmt.get();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
function hasApiKey() {
|
||||
const { count } = countApiKeysStmt.get();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
function hasCompleteSetup() {
|
||||
return hasSuperuser() && hasServer() && hasEndpoint() && hasApiKey();
|
||||
}
|
||||
|
||||
function ensureDefaultsFromEnv() {
|
||||
const envServerUrlRaw = process.env.PORTAINER_URL;
|
||||
const envServerName = process.env.PORTAINER_SERVER_NAME;
|
||||
const envEndpointIdRaw = process.env.PORTAINER_ENDPOINT_ID;
|
||||
const envEndpointName = process.env.PORTAINER_ENDPOINT_NAME;
|
||||
let server = null;
|
||||
if (envServerUrlRaw) {
|
||||
try {
|
||||
server = ensureServer({ name: envServerName, url: envServerUrlRaw });
|
||||
} catch (error) {
|
||||
console.error('⚠️ [Setup] Konnte Server aus Umgebungsvariablen nicht anlegen:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const trimmedEndpointId = typeof envEndpointIdRaw === 'string' ? envEndpointIdRaw.trim() : '';
|
||||
if (trimmedEndpointId) {
|
||||
const existingEndpoint = selectEndpointByExternalId.get(trimmedEndpointId);
|
||||
if (!existingEndpoint) {
|
||||
const targetServer = server || selectFirstServerStmt.get();
|
||||
if (targetServer) {
|
||||
try {
|
||||
const endpointName = envEndpointName || `Endpoint ${trimmedEndpointId}`;
|
||||
ensureEndpoint({
|
||||
serverId: targetServer.id,
|
||||
name: endpointName,
|
||||
externalId: trimmedEndpointId,
|
||||
makeDefault: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('⚠️ [Setup] Konnte Endpoint aus Umgebungsvariablen nicht anlegen:', error.message);
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ [Setup] Endpoint aus Umgebungsvariablen benötigt einen vorhandenen Server.');
|
||||
}
|
||||
} else if (!existingEndpoint.is_default) {
|
||||
transactionalSetDefaultEndpoint(existingEndpoint.id);
|
||||
}
|
||||
}
|
||||
|
||||
const envApiKeyRaw = typeof process.env.PORTAINER_API_KEY === 'string' ? process.env.PORTAINER_API_KEY : '';
|
||||
if (envApiKeyRaw.trim()) {
|
||||
const targetServer = server || selectFirstServerStmt.get();
|
||||
if (targetServer) {
|
||||
try {
|
||||
setServerApiKey({ serverId: targetServer.id, apiKey: envApiKeyRaw.trim() });
|
||||
} catch (error) {
|
||||
console.error('⚠️ [Setup] Konnte API-Key aus Umgebungsvariablen nicht speichern:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSetupStatus() {
|
||||
const servers = selectAllServers.all();
|
||||
const endpoints = selectAllEndpoints.all();
|
||||
const defaultEndpoint = getDefaultEndpoint();
|
||||
const apiKeyRecords = selectAllApiKeys.all();
|
||||
const apiKeyMap = new Map(apiKeyRecords.map((entry) => [entry.server_id, entry]));
|
||||
|
||||
const rawEnvServerName = typeof process.env.PORTAINER_SERVER_NAME === 'string' ? process.env.PORTAINER_SERVER_NAME : '';
|
||||
const envServerName = rawEnvServerName.trim();
|
||||
const rawEnvServerUrl = typeof process.env.PORTAINER_URL === 'string' ? process.env.PORTAINER_URL : '';
|
||||
const envServerUrl = rawEnvServerUrl.trim();
|
||||
const rawEnvEndpointName = typeof process.env.PORTAINER_ENDPOINT_NAME === 'string' ? process.env.PORTAINER_ENDPOINT_NAME : '';
|
||||
const envEndpointName = rawEnvEndpointName.trim();
|
||||
const rawEnvEndpointId = typeof process.env.PORTAINER_ENDPOINT_ID === 'string' ? process.env.PORTAINER_ENDPOINT_ID : '';
|
||||
const envEndpointId = rawEnvEndpointId.trim();
|
||||
const rawEnvApiKey = typeof process.env.PORTAINER_API_KEY === 'string' ? process.env.PORTAINER_API_KEY : '';
|
||||
const envApiKeyTrimmed = rawEnvApiKey.trim();
|
||||
const envApiKeyProvided = Boolean(envApiKeyTrimmed);
|
||||
const envSuperuserUsernameRaw = typeof process.env.SUPERUSER_USERNAME === 'string' ? process.env.SUPERUSER_USERNAME : '';
|
||||
const envSuperuserEmailRaw = typeof process.env.SUPERUSER_EMAIL === 'string' ? process.env.SUPERUSER_EMAIL : '';
|
||||
const envSuperuserPasswordRaw = typeof process.env.SUPERUSER_PASSWORD === 'string' ? process.env.SUPERUSER_PASSWORD : '';
|
||||
const envSuperuserUsername = envSuperuserUsernameRaw.trim();
|
||||
const envSuperuserEmail = envSuperuserEmailRaw.trim();
|
||||
|
||||
const serverRequired = servers.length === 0;
|
||||
const endpointRequired = endpoints.length === 0;
|
||||
|
||||
const apiKeyItems = servers.map((server) => {
|
||||
const keyMeta = apiKeyMap.get(server.id) || null;
|
||||
return {
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
hasKey: Boolean(keyMeta),
|
||||
updatedAt: keyMeta?.updated_at ?? null
|
||||
};
|
||||
});
|
||||
const apiKeyCount = apiKeyItems.filter((item) => item.hasKey).length;
|
||||
const apiKeyRequired = servers.length > 0 && apiKeyCount === 0;
|
||||
|
||||
const superuserExists = hasSuperuser();
|
||||
const setupComplete = superuserExists && !serverRequired && !endpointRequired && !apiKeyRequired;
|
||||
|
||||
return {
|
||||
superuser: {
|
||||
exists: superuserExists,
|
||||
envProvided: Boolean(envSuperuserUsername || envSuperuserEmail || envSuperuserPasswordRaw),
|
||||
env: {
|
||||
username: envSuperuserUsernameRaw,
|
||||
email: envSuperuserEmailRaw,
|
||||
password: envSuperuserPasswordRaw
|
||||
}
|
||||
},
|
||||
servers: {
|
||||
count: servers.length,
|
||||
items: servers,
|
||||
requireInput: serverRequired,
|
||||
envProvided: Boolean(envServerUrl)
|
||||
},
|
||||
endpoints: {
|
||||
count: endpoints.length,
|
||||
items: endpoints,
|
||||
requireInput: endpointRequired,
|
||||
envProvided: Boolean(envEndpointId),
|
||||
default: defaultEndpoint
|
||||
},
|
||||
apiKeys: {
|
||||
count: apiKeyCount,
|
||||
items: apiKeyItems,
|
||||
requireInput: apiKeyRequired,
|
||||
envProvided: envApiKeyProvided,
|
||||
envValue: rawEnvApiKey
|
||||
},
|
||||
requirements: {
|
||||
superuser: !superuserExists,
|
||||
server: serverRequired,
|
||||
endpoint: endpointRequired,
|
||||
apiKey: apiKeyRequired
|
||||
},
|
||||
setupComplete,
|
||||
envDefaults: {
|
||||
serverName: envServerName || (envServerUrl ? deriveServerName(envServerUrl) : ''),
|
||||
serverNameFromEnv: rawEnvServerName,
|
||||
serverUrl: envServerUrl,
|
||||
endpointName: envEndpointName || (envEndpointId ? `Endpoint ${envEndpointId}` : ''),
|
||||
endpointNameFromEnv: rawEnvEndpointName,
|
||||
endpointExternalId: envEndpointId,
|
||||
apiKeyProvided: envApiKeyProvided,
|
||||
apiKeyValue: rawEnvApiKey,
|
||||
superuserUsername: envSuperuserUsernameRaw,
|
||||
superuserEmail: envSuperuserEmailRaw,
|
||||
superuserPassword: envSuperuserPasswordRaw
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const createEndpointWithDefaultTransaction = db.transaction(({ serverInput, endpointInput }) => {
|
||||
let server = null;
|
||||
|
||||
if (endpointInput?.serverId) {
|
||||
const byId = selectServerById.get(endpointInput.serverId);
|
||||
if (!byId) {
|
||||
const error = new Error('SERVER_NOT_FOUND');
|
||||
error.code = 'SERVER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
server = byId;
|
||||
}
|
||||
|
||||
if (serverInput) {
|
||||
server = ensureServer(serverInput);
|
||||
}
|
||||
|
||||
if (!server) {
|
||||
server = selectFirstServerStmt.get();
|
||||
}
|
||||
|
||||
if (!server) {
|
||||
const error = new Error('SERVER_DETAILS_REQUIRED');
|
||||
error.code = 'SERVER_DETAILS_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!endpointInput) {
|
||||
const error = new Error('ENDPOINT_DETAILS_REQUIRED');
|
||||
error.code = 'ENDPOINT_DETAILS_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const endpoint = ensureEndpoint({
|
||||
serverId: server.id,
|
||||
name: endpointInput.name,
|
||||
externalId: endpointInput.externalId,
|
||||
makeDefault: true
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
endpoint
|
||||
};
|
||||
});
|
||||
|
||||
function completeSetup({ server: serverInput, endpoint: endpointInput }) {
|
||||
const result = createEndpointWithDefaultTransaction({ serverInput, endpointInput });
|
||||
return {
|
||||
server: result.server,
|
||||
endpoint: result.endpoint,
|
||||
defaultEndpoint: getDefaultEndpoint()
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
ensureDefaultsFromEnv,
|
||||
ensureServer,
|
||||
ensureEndpoint,
|
||||
setServerApiKey,
|
||||
setDefaultEndpoint,
|
||||
getDefaultEndpoint,
|
||||
getActiveEndpointExternalId,
|
||||
getActiveApiKey,
|
||||
getActiveServerUrl,
|
||||
hasServer,
|
||||
hasEndpoint,
|
||||
hasApiKey,
|
||||
hasCompleteSetup,
|
||||
getSetupStatus,
|
||||
completeSetup,
|
||||
removeEndpoint,
|
||||
removeServer
|
||||
};
|
||||
Reference in New Issue
Block a user