Use rms.auth.smtp_relay settings as the primary source for OTP mail transport (host/port/auth/tls/from/replyTo), with existing OTP/env values only as fallback. This lets OTP delivery work immediately when relay settings are already configured.
7877 lines
277 KiB
JavaScript
7877 lines
277 KiB
JavaScript
const http = require("http");
|
|
const fs = require("fs");
|
|
const fsp = require("fs/promises");
|
|
const path = require("path");
|
|
const crypto = require("crypto");
|
|
const { spawn } = require("child_process");
|
|
const { WebSocketServer } = require("ws");
|
|
const { createStorageProvider } = require("./storage");
|
|
|
|
const DEBUG_REMOTE_INTERFACE_ENABLED = true;
|
|
const DEBUG_REMOTE_DEFAULT_TOKEN = "dbg_8f4e5c9b71a64f2fa3e7c1d6b5a9e2f0_owrx";
|
|
|
|
const rootDir = path.resolve(__dirname, "..");
|
|
const initialEnvKeys = new Set(Object.keys(process.env));
|
|
if (!initialEnvKeys.has("DATA_DIR")) {
|
|
loadDotEnv(path.join(rootDir, ".env"));
|
|
}
|
|
const defaultDataDir = resolveDefaultDataDir(rootDir);
|
|
|
|
const config = {
|
|
port: Number(process.env.PORT || 8080),
|
|
dataDir: path.resolve(rootDir, process.env.DATA_DIR || defaultDataDir),
|
|
brandingUploadsDir: path.resolve(rootDir, process.env.BRANDING_UPLOADS_DIR || path.join(process.env.DATA_DIR || defaultDataDir, "uploads")),
|
|
vswrImagesDir: path.resolve(rootDir, process.env.VSWR_IMAGES_DIR_PATH || path.join(process.env.DATA_DIR || defaultDataDir, "vswr", "images")),
|
|
storageProvider: String(process.env.STORAGE_PROVIDER || "json").trim(),
|
|
storageModulePath: String(process.env.STORAGE_MODULE_PATH || "").trim(),
|
|
storageSqlitePath: String(process.env.STORAGE_SQLITE_PATH || "").trim(),
|
|
pluginDir: path.resolve(rootDir, process.env.PLUGIN_DIR || "./plugins"),
|
|
stationName: process.env.STATION_NAME || "ARCG Stradnerkogel",
|
|
adminEmails: new Set(
|
|
String(process.env.ADMIN_EMAILS || "")
|
|
.split(",")
|
|
.map((value) => normalizeEmail(value))
|
|
.filter(Boolean)
|
|
),
|
|
accessTokenTtlSec: Number(process.env.ACCESS_TOKEN_TTL_SEC || 10800),
|
|
refreshTokenTtlSec: Number(process.env.REFRESH_TOKEN_TTL_SEC || 60 * 60 * 24 * 14),
|
|
jwtSecret: process.env.JWT_SECRET || "change-me-in-production",
|
|
jwtIssuer: process.env.JWT_ISSUER || "rms-arcg",
|
|
jwtAudience: process.env.JWT_AUDIENCE || "rms-clients",
|
|
authRateWindowMs: Number(process.env.AUTH_RATE_WINDOW_MS || 10 * 60 * 1000),
|
|
authRateLimit: Number(process.env.AUTH_RATE_LIMIT || 25),
|
|
actionRateWindowMs: Number(process.env.ACTION_RATE_WINDOW_MS || 60 * 1000),
|
|
actionRateLimit: Number(process.env.ACTION_RATE_LIMIT || 30),
|
|
openWebRxPttLiveFreqToleranceHz: Number(process.env.OPENWEBRX_PTT_LIVE_FREQ_TOLERANCE_HZ || 50),
|
|
openWebRxLiveStateTtlMs: Number(process.env.OPENWEBRX_LIVE_STATE_TTL_MS || 10000),
|
|
openWebRxPttBlockedBandConfigIdsRaw: String(process.env.OPENWEBRX_PTT_BLOCKED_BAND_CONFIG_IDS || "27,6,24"),
|
|
openWebRxTxPollMs: Number(process.env.OPENWEBRX_TX_POLL_MS || 3000),
|
|
txAudioEnabled: String(process.env.TX_AUDIO_ENABLED || "true").trim().toLowerCase(),
|
|
txAudioAlsaDevice: String(process.env.TX_AUDIO_ALSA_DEVICE || "plughw:CARD=CODEC,DEV=0").trim(),
|
|
txAudioInputMime: String(process.env.TX_AUDIO_INPUT_MIME || "webm").trim().toLowerCase(),
|
|
txAudioStopOnDisconnect: String(process.env.TX_AUDIO_STOP_ON_DISCONNECT || "true").trim().toLowerCase(),
|
|
txAudioChunkMs: Number(process.env.TX_AUDIO_CHUNK_MS || 100),
|
|
txAudioSessionTimeoutMs: Number(process.env.TX_AUDIO_SESSION_TIMEOUT_MS || 120000),
|
|
txAudioFfmpegPath: String(process.env.TX_AUDIO_FFMPEG_PATH || "").trim(),
|
|
txAudioFfmpegExtraArgs: String(process.env.TX_AUDIO_FFMPEG_EXTRA_ARGS || "").trim(),
|
|
openWebRxPttCommandsEnabled: String(process.env.OPENWEBRX_PTT_COMMANDS_ENABLED || "false").trim().toLowerCase(),
|
|
openWebRxPttDevice: String(process.env.OPENWEBRX_PTT_DEVICE || process.env.RMS_MICROHAM_DEV || "/dev/rms-microham-u3").trim(),
|
|
openWebRxPttTimeoutMs: Number(process.env.OPENWEBRX_PTT_TIMEOUT_MS || 5000),
|
|
openWebRxPttDownCmd: String(process.env.OPENWEBRX_PTT_DOWN_CMD || "").trim(),
|
|
openWebRxPttUpCmd: String(process.env.OPENWEBRX_PTT_UP_CMD || "").trim(),
|
|
rotorDevice: String(process.env.RMS_ROTOR_DEV || process.env.RMS_FTDI_DEV || "/dev/rms-ftdi-uart").trim(),
|
|
rotorModel: Number(process.env.ROTOR_ROTCTL_MODEL || 902),
|
|
rotorSetEnabled: String(process.env.ROTOR_SET_ENABLED || "true").trim().toLowerCase(),
|
|
rotorGetTimeoutMs: Number(process.env.ROTOR_GET_TIMEOUT_MS || 10000),
|
|
rotorSetTimeoutMs: Number(process.env.ROTOR_SET_TIMEOUT_MS || 20000),
|
|
rotorMonitorMaxMs: Number(process.env.ROTOR_MONITOR_MAX_MS || 120000),
|
|
rotorStatusRetryCount: Number(process.env.ROTOR_STATUS_RETRY_COUNT || 6),
|
|
rotorStatusRetryDelayMs: Number(process.env.ROTOR_STATUS_RETRY_DELAY_MS || 400),
|
|
rotorPostSetStatusTimeoutMs: Number(process.env.ROTOR_POST_SET_STATUS_TIMEOUT_MS || 5000),
|
|
autoDisableTxBeforeActivation: String(process.env.AUTO_DISABLE_TX_BEFORE_ACTIVATION || "").trim().toLowerCase(),
|
|
stationMaxUsageSec: Number(process.env.STATION_MAX_USAGE_SEC || 3600),
|
|
primaryEmailDomain: String(process.env.PRIMARY_EMAIL_DOMAIN || "arcg.at").toLowerCase(),
|
|
publicBaseUrl: String(process.env.PUBLIC_BASE_URL || ""),
|
|
approverEmails: new Set(
|
|
String(process.env.APPROVER_EMAILS || "")
|
|
.split(",")
|
|
.map((value) => normalizeEmail(value))
|
|
.filter(Boolean)
|
|
)
|
|
};
|
|
config.execMode = resolveExecMode(process.env.RMS_EXEC_MODE, process.env.npm_lifecycle_event);
|
|
config.openWebRxPttBlockedBandConfigIds = parseBandConfigIdList(config.openWebRxPttBlockedBandConfigIdsRaw);
|
|
config.simulateHardware = config.execMode !== "prod";
|
|
if (!config.autoDisableTxBeforeActivation) {
|
|
config.autoDisableTxBeforeActivation = config.execMode === "prod" ? "true" : "false";
|
|
}
|
|
config.rootDir = rootDir;
|
|
applyRuntimePathDefaults(config, initialEnvKeys);
|
|
|
|
function resolveDefaultDataDir(appRootDir) {
|
|
const normalized = path.resolve(appRootDir);
|
|
const marker = `${path.sep}releases${path.sep}`;
|
|
const markerIdx = normalized.indexOf(marker);
|
|
if (markerIdx !== -1) {
|
|
const deployBase = normalized.slice(0, markerIdx);
|
|
return path.join(deployBase, "shared", "data");
|
|
}
|
|
return "./data";
|
|
}
|
|
|
|
function parseBandConfigIdList(raw) {
|
|
return String(raw || "")
|
|
.split(",")
|
|
.map((value) => String(value || "").trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function isPttBlockedForBandConfigId(bandConfigId) {
|
|
const selected = String(bandConfigId || "").trim();
|
|
if (!selected) {
|
|
return false;
|
|
}
|
|
return Array.isArray(config.openWebRxPttBlockedBandConfigIds)
|
|
&& config.openWebRxPttBlockedBandConfigIds.includes(selected);
|
|
}
|
|
|
|
function applyRuntimePathDefaults(appConfig, envKeysBeforeDotEnv) {
|
|
if (!envKeysBeforeDotEnv.has("DATA_DIR")) {
|
|
return;
|
|
}
|
|
const defaults = {
|
|
TX_STATE_PATH: "tx-state.json",
|
|
OPENWEBRX_BAND_STATE_PATH: "openwebrx-band-state.json",
|
|
OPENWEBRX_ACCESS_POLICY_FILE: "openwebrx-access-policy.txt",
|
|
OPENWEBRX_PERSISTENT_USERS_FILE: "openwebrx-persistent-users.txt",
|
|
BRANDING_UPLOADS_DIR: "uploads"
|
|
};
|
|
for (const [key, relative] of Object.entries(defaults)) {
|
|
if (!envKeysBeforeDotEnv.has(key)) {
|
|
process.env[key] = path.join(appConfig.dataDir, relative);
|
|
}
|
|
}
|
|
}
|
|
|
|
const files = {
|
|
users: path.join(config.dataDir, "users.json"),
|
|
station: path.join(config.dataDir, "station-state.json"),
|
|
audit: path.join(config.dataDir, "audit.log"),
|
|
auth: path.join(config.dataDir, "auth-state.json"),
|
|
plugins: path.join(config.dataDir, "plugin-state.json"),
|
|
approvals: path.join(config.dataDir, "approval-requests.json"),
|
|
system: path.join(config.dataDir, "system-state.json"),
|
|
mailOutbox: path.join(config.dataDir, "mail-outbox.log")
|
|
};
|
|
|
|
const runtime = {
|
|
users: [],
|
|
station: null,
|
|
authState: {
|
|
refreshTokens: [],
|
|
tokenVersionByUser: {},
|
|
emailTokens: [],
|
|
otpChallenges: [],
|
|
oauthChallenges: []
|
|
},
|
|
approvalRequests: [],
|
|
systemState: {
|
|
maintenanceMode: false,
|
|
maintenanceMessage: "Wartungsmodus aktiv. Login ist derzeit deaktiviert.",
|
|
branding: {
|
|
logoLightUrl: null,
|
|
logoDarkUrl: null
|
|
},
|
|
updatedAt: null
|
|
},
|
|
pluginState: {
|
|
enabled: {},
|
|
providers: {},
|
|
settings: {}
|
|
},
|
|
plugins: new Map(),
|
|
pluginHealth: {},
|
|
rateBuckets: new Map(),
|
|
jobs: new Map(),
|
|
currentActivationJobId: null,
|
|
swrRun: {
|
|
running: false,
|
|
token: null,
|
|
source: null,
|
|
phase: null,
|
|
startedAt: null,
|
|
expectedDurationMs: 0,
|
|
startedBy: null,
|
|
lastStatus: null,
|
|
lastError: null,
|
|
lastFinishedAt: null
|
|
},
|
|
txFollowRoute: null,
|
|
openWebRxAntennaRoute: null,
|
|
openWebRxSession: {
|
|
activeOwnerUserId: null,
|
|
expiresAtMs: 0,
|
|
lastEnsureSdrAtMs: 0
|
|
},
|
|
openWebRxLiveStateByUserId: {},
|
|
rotor: {
|
|
azimuth: null,
|
|
rawAzimuth: null,
|
|
moving: false,
|
|
targetAzimuth: null,
|
|
pendingTargetAzimuth: null,
|
|
pendingSource: null,
|
|
pendingRequestedByUserId: null,
|
|
queueWorkerActive: false,
|
|
statusRefreshInFlight: false,
|
|
phase: "idle",
|
|
commandInProgress: false,
|
|
commandStartedAt: null,
|
|
lastChangeAt: null,
|
|
min: 0,
|
|
max: 360,
|
|
updatedAt: null,
|
|
lastResult: null,
|
|
lastError: null
|
|
},
|
|
txAudio: {
|
|
wsServer: null,
|
|
ffmpeg: null,
|
|
clients: new Set(),
|
|
running: false,
|
|
startedAt: null,
|
|
ownerUserId: null,
|
|
alsaDevice: null,
|
|
stopRequested: false,
|
|
lastError: null,
|
|
lastExit: null,
|
|
idleTimer: null
|
|
},
|
|
pttActive: false,
|
|
sseClients: new Set(),
|
|
eventSeq: 0
|
|
};
|
|
|
|
let mutex = Promise.resolve();
|
|
let storage = null;
|
|
|
|
start().catch((error) => {
|
|
console.error("Startup failed", error);
|
|
process.exit(1);
|
|
});
|
|
|
|
async function start() {
|
|
storage = await createStorageProvider(config);
|
|
await ensureDataFiles();
|
|
runtime.users = await readJson(files.users, []);
|
|
runtime.station = await readJson(files.station, buildDefaultStationState());
|
|
runtime.authState = await readJson(files.auth, runtime.authState);
|
|
runtime.pluginState = await readJson(files.plugins, runtime.pluginState);
|
|
await migratePluginSettingsDefaults();
|
|
runtime.approvalRequests = await readJson(files.approvals, []);
|
|
runtime.systemState = normalizeSystemState(await readJson(files.system, runtime.systemState));
|
|
await reconcileBrandingFromUploads();
|
|
await loadPlugins();
|
|
await applyAdminRoles();
|
|
await reconcileStationLeaseOnStartup();
|
|
|
|
const server = http.createServer(routeRequest);
|
|
initTxAudioWebSocket(server);
|
|
server.listen(config.port, () => {
|
|
console.log(`RMS API listening on http://localhost:${config.port}`);
|
|
});
|
|
|
|
setInterval(() => {
|
|
broadcastEvent("system.heartbeat", { ok: true });
|
|
}, 20000).unref();
|
|
|
|
setInterval(() => {
|
|
refreshPluginHealth().catch(() => {});
|
|
}, 30000).unref();
|
|
|
|
setInterval(() => {
|
|
refreshRotorStatusCache().catch(() => {});
|
|
}, 2000).unref();
|
|
|
|
setInterval(() => {
|
|
enforceStationLeaseTimeout().catch(() => {});
|
|
}, 5000).unref();
|
|
|
|
setInterval(() => {
|
|
enforceOpenWebRxPttConnectionWatchdog().catch(() => {});
|
|
}, 2000).unref();
|
|
}
|
|
|
|
async function routeRequest(req, res) {
|
|
try {
|
|
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
|
const method = req.method || "GET";
|
|
|
|
if (method === "GET" && url.pathname === "/api/health") {
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
stationName: config.stationName,
|
|
maintenanceMode: Boolean(runtime.systemState.maintenanceMode),
|
|
maintenanceMessage: runtime.systemState.maintenanceMessage
|
|
});
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/public/system") {
|
|
return sendJson(res, 200, {
|
|
maintenanceMode: Boolean(runtime.systemState.maintenanceMode),
|
|
maintenanceMessage: runtime.systemState.maintenanceMessage,
|
|
branding: runtime.systemState.branding,
|
|
updatedAt: runtime.systemState.updatedAt
|
|
});
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/public/auth-methods") {
|
|
return sendJson(res, 200, { methods: listPublicAuthMethods() });
|
|
}
|
|
|
|
if (method === "POST" && (url.pathname === "/v1/auth/request-access" || url.pathname === "/v1/auth/register" || url.pathname === "/v1/auth/login")) {
|
|
if (!enforceRateLimit(req, res, `auth:request-access:${clientIp(req)}`, config.authRateLimit, config.authRateWindowMs)) {
|
|
return;
|
|
}
|
|
const body = await readJsonBody(req);
|
|
return handleRequestAccess(req, res, body);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/auth/verify-email") {
|
|
const body = await readJsonBody(req);
|
|
return handleVerifyEmail(req, res, body);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/auth/request-approval") {
|
|
const body = await readJsonBody(req);
|
|
return handleRequestApproval(req, res, body);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/auth/refresh") {
|
|
if (!enforceRateLimit(req, res, `auth:refresh:${clientIp(req)}`, config.authRateLimit, config.authRateWindowMs)) {
|
|
return;
|
|
}
|
|
const body = await readJsonBody(req);
|
|
return handleRefresh(req, res, body);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/auth/logout") {
|
|
const body = await readJsonBody(req);
|
|
return handleLogout(res, body);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/auth/logout-all") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
return handleLogoutAll(res, auth.user);
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/auth/oauth/callback") {
|
|
return handleOauthCallback(req, res, url);
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/me") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
return sendJson(res, 200, { user: sanitizeUser(auth.user), capabilities: userCapabilities(auth.user) });
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/auth/session/diag") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
return handleAuthSessionDiag(res, auth.user, auth.payload);
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/help/content") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
return handleHelpContent(res, auth.user);
|
|
}
|
|
|
|
if (method === "PUT" && url.pathname === "/v1/me/auth-method") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
const body = await readJsonBody(req);
|
|
return handleSelfAuthMethodUpdate(res, auth.user, body);
|
|
}
|
|
|
|
if (method === "PUT" && url.pathname === "/v1/me/language") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
const body = await readJsonBody(req);
|
|
return handleSelfLanguageUpdate(res, auth.user, body);
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/station/status") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
return sendJson(res, 200, buildStationStatusView());
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/swr/report") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["operator", "approver", "admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
|
|
}
|
|
const report = await buildSwrReportView(auth.user);
|
|
return sendJson(res, 200, report);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/swr/run-check") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["operator", "approver", "admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
|
|
}
|
|
if (denyIfStationOwnedByOther(res, auth.user, "SWR-Check")) {
|
|
return;
|
|
}
|
|
try {
|
|
const { result, report } = await runSwrCheckAndBuildReport(auth.user);
|
|
await appendAudit("vswr.run.manual", auth.user, { ok: true });
|
|
return sendJson(res, 200, { ok: true, result, report });
|
|
} catch (error) {
|
|
await appendAudit("vswr.run.manual", auth.user, { ok: false, error: String(error && error.message ? error.message : error) });
|
|
if (error && error.code === "TX_SWITCH_LOCK") {
|
|
return sendError(res, 409, "tx.switch_locked", String(error.message || error), error.details || null);
|
|
}
|
|
if (error && error.code === "OPENWEBRX_SESSION_ACTIVE") {
|
|
return sendError(res, 409, "vswr.unsafe_openwebrx_active", String(error.message || error));
|
|
}
|
|
if (error && error.code === "VSWR_WHILE_STATION_ACTIVE") {
|
|
return sendError(res, 409, "vswr.unsafe_station_active", String(error.message || error));
|
|
}
|
|
if (error && error.code === "SWR_ALREADY_RUNNING") {
|
|
return sendError(res, 409, "swr.running", String(error.message || error));
|
|
}
|
|
return sendError(res, 500, "vswr.run.failed", String(error && error.message ? error.message : error));
|
|
}
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/station/activation-jobs") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["operator", "approver", "admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
|
|
}
|
|
if (denyIfStationOwnedByOther(res, auth.user, "Aktivierung")) {
|
|
return;
|
|
}
|
|
if (!enforceRateLimit(req, res, `station:activate:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
|
|
return;
|
|
}
|
|
return handleActivationStart(res, auth.user);
|
|
}
|
|
|
|
if (method === "GET" && url.pathname.startsWith("/v1/station/activation-jobs/")) {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
const jobId = decodeURIComponent(url.pathname.slice("/v1/station/activation-jobs/".length));
|
|
const job = runtime.jobs.get(jobId);
|
|
if (!job) {
|
|
return sendError(res, 404, "station.job.not_found", "Job nicht gefunden");
|
|
}
|
|
return sendJson(res, 200, { job });
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/station/release") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (denyIfStationOwnedByOther(res, auth.user, "Freigabe", { allowAdmin: true })) {
|
|
return;
|
|
}
|
|
if (!enforceRateLimit(req, res, `station:release:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
|
|
return;
|
|
}
|
|
return handleStationRelease(res, auth.user);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/station/reservations/next") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["operator", "approver", "admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
|
|
}
|
|
if (!enforceRateLimit(req, res, `station:reserve-next:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
|
|
return;
|
|
}
|
|
return handleReserveNextSlot(res, auth.user);
|
|
}
|
|
|
|
if (method === "DELETE" && url.pathname === "/v1/station/reservations/next") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["operator", "approver", "admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
|
|
}
|
|
if (!enforceRateLimit(req, res, `station:reserve-delete:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
|
|
return;
|
|
}
|
|
return handleDeleteOwnReservation(res, auth.user);
|
|
}
|
|
|
|
if (method === "DELETE" && url.pathname.startsWith("/v1/station/reservations/")) {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
|
|
}
|
|
const encodedUserId = decodeURIComponent(url.pathname.slice("/v1/station/reservations/".length) || "").trim();
|
|
if (!encodedUserId || encodedUserId === "next") {
|
|
return sendError(res, 400, "station.reservation.user_missing", "Benutzer-ID fuer Reservierungsloeschung fehlt");
|
|
}
|
|
if (!enforceRateLimit(req, res, `station:reserve-delete-admin:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
|
|
return;
|
|
}
|
|
return handleDeleteReservationByUserId(res, auth.user, encodedUserId, true);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/openwebrx/session") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX Session")) {
|
|
return;
|
|
}
|
|
if (!enforceRateLimit(req, res, `openwebrx:session:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
|
|
return;
|
|
}
|
|
return handleOpenWebRxSessionIssue(res, auth.user);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/openwebrx/tx/enable") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX TX")) {
|
|
return;
|
|
}
|
|
if (!enforceRateLimit(req, res, `openwebrx:tx-enable:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
|
|
return;
|
|
}
|
|
return handleOpenWebRxTxEnable(res, auth.user);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/openwebrx/tx/disable") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX TX")) {
|
|
return;
|
|
}
|
|
if (!enforceRateLimit(req, res, `openwebrx:tx-disable:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
|
|
return;
|
|
}
|
|
return handleOpenWebRxTxDisable(res, auth.user);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/openwebrx/ptt/down") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX PTT")) {
|
|
return;
|
|
}
|
|
if (!enforceRateLimit(req, res, `openwebrx:ptt-down:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
|
|
return;
|
|
}
|
|
return handleOpenWebRxPttDown(res, auth.user);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/openwebrx/ptt/up") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX PTT")) {
|
|
return;
|
|
}
|
|
if (!enforceRateLimit(req, res, `openwebrx:ptt-up:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
|
|
return;
|
|
}
|
|
return handleOpenWebRxPttUp(res, auth.user);
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/openwebrx/tx/status") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
return handleOpenWebRxTxStatus(res, auth.user);
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/openwebrx/rotor/status") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
return handleOpenWebRxRotorStatus(res, auth.user);
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/openwebrx/bands") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
return handleOpenWebRxBands(res, auth.user);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/openwebrx/bands/select") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX Bandwechsel")) {
|
|
return;
|
|
}
|
|
if (!enforceRateLimit(req, res, `openwebrx:band-select:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
|
|
return;
|
|
}
|
|
const body = await readJsonBody(req);
|
|
return handleOpenWebRxBandSelect(res, auth.user, body);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/openwebrx/rotor/set") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX Rotor")) {
|
|
return;
|
|
}
|
|
if (!enforceRateLimit(req, res, `openwebrx:rotor-set:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
|
|
return;
|
|
}
|
|
const body = await readJsonBody(req);
|
|
return handleOpenWebRxRotorSet(res, auth.user, body, "api");
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/openwebrx/session/close") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX Session", { allowAdmin: true })) {
|
|
return;
|
|
}
|
|
if (!enforceRateLimit(req, res, `openwebrx:session-close:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
|
|
return;
|
|
}
|
|
return handleOpenWebRxSessionClose(res, auth.user);
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/openwebrx/plugin/state") {
|
|
return handleOpenWebRxPluginState(req, res, url);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/tx/enable") {
|
|
return handleOpenWebRxPluginTx(req, res, url, true);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/tx/disable") {
|
|
return handleOpenWebRxPluginTx(req, res, url, false);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/ptt/down") {
|
|
return handleOpenWebRxPluginPtt(req, res, url, true);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/ptt/up") {
|
|
return handleOpenWebRxPluginPtt(req, res, url, false);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/live-state") {
|
|
const body = await readJsonBody(req);
|
|
return handleOpenWebRxPluginLiveState(req, res, url, body);
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/openwebrx/plugin/audio/status") {
|
|
return handleOpenWebRxPluginAudioStatus(req, res, url);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/audio/connect") {
|
|
return handleOpenWebRxPluginAudioConnect(req, res, url);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/audio/disconnect") {
|
|
return handleOpenWebRxPluginAudioDisconnect(req, res, url);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/bands/select") {
|
|
const body = await readJsonBody(req);
|
|
return handleOpenWebRxPluginBandSelect(req, res, url, body);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/rotor/set") {
|
|
const body = await readJsonBody(req);
|
|
return handleOpenWebRxPluginRotorSet(req, res, url, body);
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/openwebrx/authorize") {
|
|
return handleOpenWebRxAuthorize(req, res, url);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/debug/collect/owrx") {
|
|
return handleDebugCollect(req, res, url, "owrx");
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/debug/logs/owrx") {
|
|
return handleDebugLogs(req, res, url, "owrx");
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/debug/clear/owrx") {
|
|
return handleDebugClear(req, res, url, "owrx");
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/debug/snapshot/owrx") {
|
|
return handleDebugSnapshot(req, res, url, "owrx");
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/debug/collect/usb") {
|
|
return handleDebugCollect(req, res, url, "usb");
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/debug/logs/usb") {
|
|
return handleDebugLogs(req, res, url, "usb");
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/debug/clear/usb") {
|
|
return handleDebugClear(req, res, url, "usb");
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/debug/snapshot/usb") {
|
|
return handleDebugSnapshot(req, res, url, "usb");
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/debug/collect/alsa") {
|
|
return handleDebugCollect(req, res, url, "alsa");
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/debug/logs/alsa") {
|
|
return handleDebugLogs(req, res, url, "alsa");
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/debug/clear/alsa") {
|
|
return handleDebugClear(req, res, url, "alsa");
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/debug/snapshot/alsa") {
|
|
return handleDebugSnapshot(req, res, url, "alsa");
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/debug/collect/soapy") {
|
|
return handleDebugCollect(req, res, url, "soapy");
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/debug/logs/soapy") {
|
|
return handleDebugLogs(req, res, url, "soapy");
|
|
}
|
|
|
|
if (method === "POST" && url.pathname === "/v1/debug/clear/soapy") {
|
|
return handleDebugClear(req, res, url, "soapy");
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/debug/snapshot/soapy") {
|
|
return handleDebugSnapshot(req, res, url, "soapy");
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/debug/which") {
|
|
return handleDebugWhich(req, res, url);
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/ui/controls") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
return sendJson(res, 200, { controls: await buildUiControls(auth.user) });
|
|
}
|
|
|
|
if (method === "POST" && url.pathname.startsWith("/v1/ui/controls/") && url.pathname.includes("/actions/")) {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!enforceRateLimit(req, res, `ui:action:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
|
|
return;
|
|
}
|
|
const body = await readJsonBody(req);
|
|
return handleUiControlAction(req, res, auth.user, url.pathname, body);
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/plugins") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nur Admin");
|
|
}
|
|
return sendJson(res, 200, {
|
|
plugins: await listPlugins(),
|
|
providers: runtime.pluginState.providers,
|
|
capabilities: await buildCapabilitiesMatrix()
|
|
});
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/admin/capabilities") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nur Admin");
|
|
}
|
|
return sendJson(res, 200, { capabilities: await buildCapabilitiesMatrix() });
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/admin/users") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["admin", "approver"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
|
|
}
|
|
return sendJson(res, 200, { users: runtime.users.map((entry) => sanitizeUser(entry)) });
|
|
}
|
|
|
|
if (method === "PUT" && url.pathname.match(/^\/v1\/admin\/users\/[^/]+\/role$/)) {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nur Admin");
|
|
}
|
|
const body = await readJsonBody(req);
|
|
return handleAdminUserRoleUpdate(res, auth.user, url.pathname, body);
|
|
}
|
|
|
|
if (method === "PUT" && url.pathname.match(/^\/v1\/admin\/users\/[^/]+\/auth-methods$/)) {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nur Admin");
|
|
}
|
|
const body = await readJsonBody(req);
|
|
return handleAdminUserAuthMethodsUpdate(res, auth.user, url.pathname, body);
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/approvals") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["admin", "approver"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
|
|
}
|
|
return sendJson(res, 200, { approvals: listApprovalsView() });
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/activity-log") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["admin", "approver"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
|
|
}
|
|
const limitRaw = Number(url.searchParams.get("limit") || 200);
|
|
const limit = Math.max(20, Math.min(1000, Number.isFinite(limitRaw) ? limitRaw : 200));
|
|
const entries = await listStationActivityLog(limit);
|
|
return sendJson(res, 200, { entries });
|
|
}
|
|
|
|
if (method === "POST" && url.pathname.match(/^\/v1\/approvals\/[^/]+\/(approve|reject)$/)) {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["admin", "approver"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
|
|
}
|
|
return handleApprovalDecision(res, auth.user, url.pathname);
|
|
}
|
|
|
|
if (method === "PUT" && url.pathname === "/v1/admin/maintenance") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nur Admin");
|
|
}
|
|
const body = await readJsonBody(req);
|
|
return handleMaintenanceUpdate(res, auth.user, body);
|
|
}
|
|
|
|
if (method === "PUT" && url.pathname === "/v1/admin/branding/logo") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nur Admin");
|
|
}
|
|
const body = await readJsonBody(req);
|
|
return handleAdminBrandingLogoUpdate(res, auth.user, body);
|
|
}
|
|
|
|
if (method === "DELETE" && url.pathname === "/v1/admin/branding/logo") {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nur Admin");
|
|
}
|
|
const theme = String(url.searchParams.get("theme") || "").toLowerCase();
|
|
return handleAdminBrandingLogoDelete(res, auth.user, theme);
|
|
}
|
|
|
|
if (method === "GET" && url.pathname.match(/^\/v1\/plugins\/[^/]+\/settings-schema$/)) {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nur Admin");
|
|
}
|
|
return handlePluginSettingsSchema(res, url.pathname);
|
|
}
|
|
|
|
if (method === "GET" && url.pathname.match(/^\/v1\/plugins\/[^/]+\/settings$/)) {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nur Admin");
|
|
}
|
|
return handlePluginSettingsGet(res, url.pathname);
|
|
}
|
|
|
|
if (method === "PUT" && url.pathname.match(/^\/v1\/plugins\/[^/]+\/settings$/)) {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nur Admin");
|
|
}
|
|
const body = await readJsonBody(req);
|
|
return handlePluginSettingsPut(res, auth.user, url.pathname, body);
|
|
}
|
|
|
|
if (method === "POST" && url.pathname.match(/^\/v1\/plugins\/[^/]+\/(enable|disable)$/)) {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nur Admin");
|
|
}
|
|
return handlePluginToggle(req, res, auth.user, url.pathname);
|
|
}
|
|
|
|
if (method === "PUT" && url.pathname.startsWith("/v1/admin/capabilities/") && url.pathname.endsWith("/provider")) {
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) return;
|
|
if (!hasRole(auth.user, ["admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nur Admin");
|
|
}
|
|
const body = await readJsonBody(req);
|
|
return handleCapabilityProviderSwitch(res, auth.user, url.pathname, body);
|
|
}
|
|
|
|
if (method === "GET" && url.pathname === "/v1/events/stream") {
|
|
return handleEventStream(req, res, url);
|
|
}
|
|
|
|
if (method === "GET" || method === "HEAD") {
|
|
return serveStaticFile(rootDir, req, res, url.pathname);
|
|
}
|
|
|
|
return sendError(res, 404, "route.not_found", "Nicht gefunden");
|
|
} catch (error) {
|
|
if (error && error.code === "INVALID_JSON") {
|
|
return sendError(res, 400, "request.invalid_json", "Ungueltiges JSON");
|
|
}
|
|
console.error("Unhandled request error", error);
|
|
return sendError(res, 500, "server.error", "Interner Fehler");
|
|
}
|
|
}
|
|
|
|
async function handleRequestAccess(req, res, body) {
|
|
const email = normalizeEmail(body && body.email);
|
|
const requestedMethod = typeof (body && body.method) === "string" ? body.method.trim() : "";
|
|
if (!isValidEmail(email)) {
|
|
return sendError(res, 400, "auth.invalid_email", "Bitte gueltige E-Mail angeben");
|
|
}
|
|
|
|
let user = runtime.users.find((entry) => entry.email === email);
|
|
if (runtime.systemState.maintenanceMode) {
|
|
const canBypass = (user && user.role === "admin") || config.adminEmails.has(email);
|
|
if (!canBypass) {
|
|
return sendError(res, 503, "auth.maintenance", runtime.systemState.maintenanceMessage);
|
|
}
|
|
}
|
|
const nowIso = new Date().toISOString();
|
|
if (!user) {
|
|
const role = runtime.users.length === 0 || config.adminEmails.has(email)
|
|
? "admin"
|
|
: (config.approverEmails.has(email) ? "approver" : "operator");
|
|
user = {
|
|
id: crypto.randomUUID(),
|
|
email,
|
|
role,
|
|
status: "pending_verification",
|
|
accountType: isPrimaryDomainEmail(email) ? "primary-domain" : "external-domain",
|
|
enabledAuthMethods: listPublicAuthMethods().map((entry) => entry.id),
|
|
primaryAuthMethod: preferredAuthMethodId(listPublicAuthMethods()) || null,
|
|
preferredLanguage: "de",
|
|
createdAt: nowIso,
|
|
emailVerifiedAt: null,
|
|
approvedAt: null,
|
|
deniedAt: null
|
|
};
|
|
runtime.users.push(user);
|
|
await writeJson(files.users, runtime.users);
|
|
await appendAudit("auth.register_email", user, { accountType: user.accountType });
|
|
}
|
|
|
|
if (user.status === "denied") {
|
|
return sendError(res, 403, "auth.access_denied", "Kein Zugriff. Bitte Freigabe anfordern.", {
|
|
requestApprovalUrl: `${publicBaseUrlFor(req)}/login?requestApproval=1&email=${encodeURIComponent(email)}`
|
|
});
|
|
}
|
|
|
|
const selectedMethod = resolveAuthMethodForUser(user, requestedMethod);
|
|
if (requestedMethod && !selectedMethod) {
|
|
return sendError(res, 400, "auth.method_unavailable", "Gewaehlte Bestaetigungsart ist fuer dieses Konto nicht verfuegbar");
|
|
}
|
|
if (!selectedMethod) {
|
|
const token = await issueEmailToken(user.id, user.status === "active" ? "login" : "verify");
|
|
const actionPath = user.status === "active" ? "/login?loginToken=" : "/login?verifyToken=";
|
|
const link = `${publicBaseUrlFor(req)}${actionPath}${encodeURIComponent(token)}`;
|
|
const message = buildAuthEmailMessage(req, {
|
|
user,
|
|
type: "link",
|
|
subject: user.status === "active" ? "ARCG Login-Link" : "ARCG E-Mail bestaetigen",
|
|
text: user.status === "active"
|
|
? `Dein Login-Link: ${link}`
|
|
: `Bitte E-Mail bestaetigen: ${link}`,
|
|
actionLink: link,
|
|
actionLabel: user.status === "active" ? "Jetzt einloggen" : "E-Mail bestaetigen"
|
|
});
|
|
await sendEmailMessage(
|
|
email,
|
|
message.subject,
|
|
message.text,
|
|
message.html
|
|
);
|
|
await appendAudit("auth.request_access", user, {
|
|
status: user.status,
|
|
requestedMethod: requestedMethod || null,
|
|
method: "fallback-mail"
|
|
});
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
method: "smtp-link",
|
|
challengeType: "link",
|
|
challengeHint: "Fallback per Mail aktiv",
|
|
message: user.status === "active"
|
|
? "Login-Link wurde per E-Mail versendet."
|
|
: "Bestaetigungslink wurde per E-Mail versendet.",
|
|
domainAllowed: isPrimaryDomainEmail(email),
|
|
requestApprovalHint: !isPrimaryDomainEmail(email)
|
|
? "Adresse ausserhalb der Hauptdomain: Nach Bestaetigung wird eine Freigabe angefordert."
|
|
: null
|
|
});
|
|
}
|
|
|
|
let challengeType = selectedMethod.type;
|
|
let challengeHint = null;
|
|
if (selectedMethod.type === "link") {
|
|
const token = await issueEmailToken(user.id, user.status === "active" ? "login" : "verify");
|
|
const actionPath = user.status === "active" ? "/login?loginToken=" : "/login?verifyToken=";
|
|
const link = `${publicBaseUrlFor(req)}${actionPath}${encodeURIComponent(token)}`;
|
|
const message = buildAuthEmailMessage(req, {
|
|
user,
|
|
type: "link",
|
|
subject: user.status === "active" ? "ARCG Login-Link" : "ARCG E-Mail bestaetigen",
|
|
text: user.status === "active"
|
|
? `Dein Login-Link: ${link}`
|
|
: `Bitte E-Mail bestaetigen: ${link}`,
|
|
actionLink: link,
|
|
actionLabel: user.status === "active" ? "Jetzt einloggen" : "E-Mail bestaetigen"
|
|
});
|
|
await dispatchAuthChallenge(req, user, selectedMethod, {
|
|
type: "link",
|
|
subject: message.subject,
|
|
text: message.text,
|
|
html: message.html,
|
|
token,
|
|
link
|
|
});
|
|
} else if (selectedMethod.type === "otp") {
|
|
const otpCode = await issueOtpChallenge(user.id, user.status === "active" ? "login" : "verify");
|
|
challengeHint = `Code wurde ueber ${selectedMethod.label} gesendet.`;
|
|
const message = buildAuthEmailMessage(req, {
|
|
user,
|
|
type: "otp",
|
|
subject: user.status === "active" ? "ARCG Login-Code" : "ARCG Bestaetigungs-Code",
|
|
text: `Dein Code lautet: ${otpCode}`,
|
|
code: otpCode
|
|
});
|
|
await dispatchAuthChallenge(req, user, selectedMethod, {
|
|
type: "otp",
|
|
subject: message.subject,
|
|
text: message.text,
|
|
html: message.html,
|
|
code: otpCode
|
|
});
|
|
} else if (selectedMethod.type === "oauth") {
|
|
const purpose = user.status === "active" ? "login" : "verify";
|
|
const stateToken = await issueOauthChallenge(user.id, purpose, selectedMethod.id);
|
|
const plugin = runtime.plugins.get(selectedMethod.pluginId);
|
|
if (!plugin || typeof plugin.instance.execute !== "function") {
|
|
return sendError(res, 409, "auth.oauth_not_configured", "OAuth Provider ist nicht konfiguriert");
|
|
}
|
|
const callbackUrl = `${publicBaseUrlFor(req)}/v1/auth/oauth/callback`;
|
|
const oauthResult = await plugin.instance.execute("start_oauth", {
|
|
methodId: selectedMethod.id,
|
|
methodType: selectedMethod.type,
|
|
user: sanitizeUser(user),
|
|
recipient: user.email,
|
|
origin: publicBaseUrlFor(req),
|
|
payload: {
|
|
purpose,
|
|
state: stateToken,
|
|
callbackUrl
|
|
}
|
|
}, { user });
|
|
const authorizeUrl = oauthResult && oauthResult.authorizeUrl ? String(oauthResult.authorizeUrl) : "";
|
|
if (!authorizeUrl) {
|
|
return sendError(res, 409, "auth.oauth_not_configured", "OAuth Provider liefert keine Authorize-URL");
|
|
}
|
|
challengeType = "oauth";
|
|
challengeHint = `Anmeldung ueber ${selectedMethod.label}`;
|
|
await appendAudit("auth.request_access", user, {
|
|
status: user.status,
|
|
requestedMethod: requestedMethod || null,
|
|
method: selectedMethod.id,
|
|
challengeType,
|
|
oauthRedirect: true
|
|
});
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
method: selectedMethod.id,
|
|
challengeType,
|
|
challengeHint,
|
|
authorizeUrl,
|
|
message: "Weiterleitung zum OAuth-Provider...",
|
|
domainAllowed: isPrimaryDomainEmail(email),
|
|
requestApprovalHint: !isPrimaryDomainEmail(email)
|
|
? "Adresse ausserhalb der Hauptdomain: Nach Bestaetigung wird eine Freigabe angefordert."
|
|
: null
|
|
});
|
|
} else {
|
|
return sendError(res, 400, "auth.method_invalid", "Unbekannte Bestaetigungsart");
|
|
}
|
|
|
|
await appendAudit("auth.request_access", user, {
|
|
status: user.status,
|
|
requestedMethod: requestedMethod || null,
|
|
method: selectedMethod.id,
|
|
challengeType
|
|
});
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
method: selectedMethod.id,
|
|
challengeType,
|
|
challengeHint,
|
|
message: challengeType === "otp"
|
|
? "Code wurde versendet."
|
|
: user.status === "active"
|
|
? "Login-Link wurde per E-Mail versendet."
|
|
: "Bestaetigungslink wurde per E-Mail versendet.",
|
|
domainAllowed: isPrimaryDomainEmail(email),
|
|
requestApprovalHint: !isPrimaryDomainEmail(email)
|
|
? "Adresse ausserhalb der Hauptdomain: Nach Bestaetigung wird eine Freigabe angefordert."
|
|
: null
|
|
});
|
|
}
|
|
|
|
async function handleVerifyEmail(req, res, body) {
|
|
const token = typeof (body && body.token) === "string" ? body.token : "";
|
|
const otpEmail = normalizeEmail(body && body.email);
|
|
const otpCode = typeof (body && body.code) === "string" ? body.code.trim() : "";
|
|
|
|
let verification = null;
|
|
if (token) {
|
|
verification = consumeEmailToken(token);
|
|
} else if (otpEmail && otpCode) {
|
|
const otpUser = runtime.users.find((entry) => entry.email === otpEmail);
|
|
if (!otpUser) {
|
|
return sendError(res, 404, "auth.user_not_found", "Benutzer nicht gefunden");
|
|
}
|
|
verification = consumeOtpChallenge(otpUser.id, otpCode);
|
|
} else {
|
|
return sendError(res, 400, "auth.token_missing", "Token oder OTP-Code fehlt");
|
|
}
|
|
|
|
if (!verification.ok) {
|
|
return sendError(res, 400, "auth.token_invalid", verification.message || "Token ungueltig");
|
|
}
|
|
|
|
const user = runtime.users.find((entry) => entry.id === verification.userId);
|
|
if (!user) {
|
|
return sendError(res, 404, "auth.user_not_found", "Benutzer nicht gefunden");
|
|
}
|
|
|
|
const result = await finalizeLoginForVerification(req, user, verification.purpose, verification.purpose);
|
|
if (result && result.error) {
|
|
return sendError(res, result.error.status, result.error.code, result.error.message);
|
|
}
|
|
return sendJson(res, 200, result);
|
|
}
|
|
|
|
async function handleOauthCallback(req, res, url) {
|
|
const error = String(url.searchParams.get("error") || "").trim();
|
|
const errorDescription = String(url.searchParams.get("error_description") || "").trim();
|
|
const stateToken = String(url.searchParams.get("state") || "").trim();
|
|
const code = String(url.searchParams.get("code") || "").trim();
|
|
|
|
if (error) {
|
|
return redirectToLoginWithAuthError(res, `oauth_${error}`, errorDescription || "OAuth Login fehlgeschlagen");
|
|
}
|
|
if (!stateToken || !code) {
|
|
return redirectToLoginWithAuthError(res, "auth.oauth_missing", "OAuth Parameter fehlen");
|
|
}
|
|
|
|
const challenge = consumeOauthChallenge(stateToken);
|
|
if (!challenge.ok) {
|
|
return redirectToLoginWithAuthError(res, "auth.oauth_invalid", challenge.message || "OAuth state ungueltig");
|
|
}
|
|
|
|
const user = runtime.users.find((entry) => String(entry.id || "") === String(challenge.userId || ""));
|
|
if (!user) {
|
|
return redirectToLoginWithAuthError(res, "auth.user_not_found", "Benutzer nicht gefunden");
|
|
}
|
|
|
|
const method = findPublicAuthMethodById(challenge.methodId);
|
|
if (!method || method.type !== "oauth") {
|
|
return redirectToLoginWithAuthError(res, "auth.oauth_method_invalid", "OAuth Methode ist nicht verfuegbar");
|
|
}
|
|
|
|
const plugin = runtime.plugins.get(method.pluginId);
|
|
if (!plugin || typeof plugin.instance.execute !== "function") {
|
|
return redirectToLoginWithAuthError(res, "auth.oauth_not_configured", "OAuth Provider ist nicht konfiguriert");
|
|
}
|
|
|
|
try {
|
|
const callbackUrl = `${publicBaseUrlFor(req)}/v1/auth/oauth/callback`;
|
|
const oauthResult = await plugin.instance.execute("finish_oauth", {
|
|
methodId: method.id,
|
|
methodType: method.type,
|
|
user: sanitizeUser(user),
|
|
recipient: user.email,
|
|
origin: publicBaseUrlFor(req),
|
|
payload: {
|
|
code,
|
|
state: stateToken,
|
|
callbackUrl,
|
|
challengePurpose: challenge.purpose
|
|
}
|
|
}, { user });
|
|
|
|
const oauthEmail = normalizeEmail(oauthResult && oauthResult.email ? oauthResult.email : user.email);
|
|
if (!oauthEmail || oauthEmail !== normalizeEmail(user.email)) {
|
|
await appendAudit("auth.oauth.email_mismatch", user, {
|
|
method: method.id,
|
|
oauthEmail: oauthEmail || null
|
|
});
|
|
return redirectToLoginWithAuthError(res, "auth.oauth_email_mismatch", "OAuth E-Mail passt nicht zum angeforderten Konto");
|
|
}
|
|
|
|
const finalPurpose = challenge.purpose === "verify" ? "verifyToken" : "loginToken";
|
|
const token = await issueEmailToken(user.id, challenge.purpose === "verify" ? "verify" : "login");
|
|
await appendAudit("auth.oauth.callback", user, {
|
|
method: method.id,
|
|
purpose: challenge.purpose
|
|
});
|
|
res.writeHead(302, { Location: `/login?${finalPurpose}=${encodeURIComponent(token)}` });
|
|
res.end();
|
|
} catch (oauthError) {
|
|
const message = String(oauthError && oauthError.message ? oauthError.message : oauthError);
|
|
await appendAudit("auth.oauth.callback.failed", user, {
|
|
method: method.id,
|
|
purpose: challenge.purpose,
|
|
error: message
|
|
});
|
|
return redirectToLoginWithAuthError(res, "auth.oauth_failed", message || "OAuth Callback fehlgeschlagen");
|
|
}
|
|
}
|
|
|
|
async function handleRequestApproval(req, res, body) {
|
|
const email = normalizeEmail(body && body.email);
|
|
if (!isValidEmail(email)) {
|
|
return sendError(res, 400, "auth.invalid_email", "Bitte gueltige E-Mail angeben");
|
|
}
|
|
const user = runtime.users.find((entry) => entry.email === email);
|
|
if (!user) {
|
|
return sendError(res, 404, "auth.user_not_found", "Benutzer nicht gefunden");
|
|
}
|
|
if (user.accountType === "primary-domain") {
|
|
return sendError(res, 409, "approval.not_needed", "Fuer diese Domain ist keine Freigabe erforderlich");
|
|
}
|
|
if (!user.emailVerifiedAt) {
|
|
return sendError(res, 409, "approval.email_not_verified", "Bitte zuerst E-Mail bestaetigen");
|
|
}
|
|
|
|
user.status = "pending_approval";
|
|
user.deniedAt = null;
|
|
await writeJson(files.users, runtime.users);
|
|
await createApprovalRequest(user, req);
|
|
await appendAudit("auth.request_approval", user, null);
|
|
return sendJson(res, 200, { ok: true, message: "Freigabe wurde angefordert" });
|
|
}
|
|
|
|
async function handleRefresh(req, res, body) {
|
|
const refreshToken = typeof (body && body.refreshToken) === "string" ? body.refreshToken : "";
|
|
if (!refreshToken) {
|
|
return sendError(res, 400, "auth.refresh_missing", "refreshToken fehlt");
|
|
}
|
|
|
|
const verified = verifyJwt(refreshToken, "refresh");
|
|
if (!verified.ok) {
|
|
return sendError(res, 401, "auth.invalid_refresh", "Refresh Token ungueltig");
|
|
}
|
|
|
|
const payload = verified.payload;
|
|
const user = runtime.users.find((entry) => entry.id === payload.sub);
|
|
if (!user) {
|
|
return sendError(res, 401, "auth.invalid_refresh", "Refresh Token ungueltig");
|
|
}
|
|
if (runtime.systemState.maintenanceMode && user.role !== "admin") {
|
|
return sendError(res, 503, "auth.maintenance", runtime.systemState.maintenanceMessage);
|
|
}
|
|
if (user.status !== "active") {
|
|
return sendError(res, 403, "auth.not_approved", "Benutzer ist nicht freigegeben");
|
|
}
|
|
|
|
const tokenVersion = runtime.authState.tokenVersionByUser[user.id] || 0;
|
|
if (payload.tv !== tokenVersion) {
|
|
return sendError(res, 401, "auth.invalid_refresh", "Refresh Token ungueltig");
|
|
}
|
|
|
|
const tokenHash = sha256(refreshToken);
|
|
const record = runtime.authState.refreshTokens.find((entry) => entry.id === payload.rid);
|
|
if (!record || record.tokenHash !== tokenHash) {
|
|
return sendError(res, 401, "auth.invalid_refresh", "Refresh Token ungueltig");
|
|
}
|
|
|
|
if (record.revokedAt) {
|
|
await revokeSessionFamily(record.sid);
|
|
await appendAudit("auth.refresh.reuse_detected", user, { sid: record.sid, rid: record.id });
|
|
return sendError(res, 401, "auth.refresh_reuse", "Refresh Token bereits verwendet");
|
|
}
|
|
if (Date.now() > record.expiresAtMs) {
|
|
return sendError(res, 401, "auth.refresh_expired", "Refresh Token abgelaufen");
|
|
}
|
|
|
|
record.revokedAt = new Date().toISOString();
|
|
const next = await issueTokenPair(user, req, { sid: record.sid, rotatedFrom: record.id });
|
|
await saveAuthState();
|
|
await appendAudit("auth.refresh", user, { sid: record.sid, rid: next.rid });
|
|
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
user: sanitizeUser(user),
|
|
accessToken: next.accessToken,
|
|
refreshToken: next.refreshToken,
|
|
expiresInSec: config.accessTokenTtlSec
|
|
});
|
|
}
|
|
|
|
async function handleLogout(res, body) {
|
|
const refreshToken = typeof (body && body.refreshToken) === "string" ? body.refreshToken : "";
|
|
if (!refreshToken) {
|
|
return sendJson(res, 200, { ok: true });
|
|
}
|
|
|
|
const verified = verifyJwt(refreshToken, "refresh");
|
|
if (!verified.ok) {
|
|
return sendJson(res, 200, { ok: true });
|
|
}
|
|
const record = runtime.authState.refreshTokens.find((entry) => entry.id === verified.payload.rid);
|
|
if (record && !record.revokedAt) {
|
|
record.revokedAt = new Date().toISOString();
|
|
await saveAuthState();
|
|
}
|
|
return sendJson(res, 200, { ok: true });
|
|
}
|
|
|
|
async function handleLogoutAll(res, user) {
|
|
runtime.authState.tokenVersionByUser[user.id] = (runtime.authState.tokenVersionByUser[user.id] || 0) + 1;
|
|
for (const token of runtime.authState.refreshTokens) {
|
|
if (token.userId === user.id && !token.revokedAt) {
|
|
token.revokedAt = new Date().toISOString();
|
|
}
|
|
}
|
|
await saveAuthState();
|
|
await appendAudit("auth.logout_all", user, null);
|
|
return sendJson(res, 200, { ok: true });
|
|
}
|
|
|
|
async function handleAuthSessionDiag(res, user, payload) {
|
|
if (!hasRole(user, ["admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nur Admin");
|
|
}
|
|
const nowMs = Date.now();
|
|
const nowSec = Math.floor(nowMs / 1000);
|
|
const expSec = Number(payload && payload.exp ? payload.exp : 0);
|
|
const tokenRemainingSec = expSec > 0 ? Math.max(0, expSec - nowSec) : 0;
|
|
const ttlDetails = getEffectiveAccessTokenTtlDetails(user, nowSec);
|
|
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
token: {
|
|
expiresAt: expSec > 0 ? new Date(expSec * 1000).toISOString() : null,
|
|
remainingSec: tokenRemainingSec
|
|
},
|
|
config: {
|
|
configuredAccessTokenTtlSec: Number.isFinite(Number(config.accessTokenTtlSec)) ? Math.floor(Number(config.accessTokenTtlSec)) : null,
|
|
minimumAccessTokenTtlSec: ttlDetails.minimumTtlSec,
|
|
effectiveNextIssueTtlSec: ttlDetails.effectiveTtlSec
|
|
},
|
|
station: {
|
|
isInUse: Boolean(runtime.station && runtime.station.isInUse),
|
|
activeByUserId: runtime.station && runtime.station.activeByUserId ? String(runtime.station.activeByUserId) : null,
|
|
endsAt: runtime.station && runtime.station.endsAt ? runtime.station.endsAt : null,
|
|
ownerSessionBoostApplied: Boolean(ttlDetails.ownerSessionBoostApplied)
|
|
}
|
|
});
|
|
}
|
|
|
|
async function handleAdminUserRoleUpdate(res, actor, pathname, body) {
|
|
const match = pathname.match(/^\/v1\/admin\/users\/([^/]+)\/role$/);
|
|
if (!match) {
|
|
return sendError(res, 404, "user.not_found", "Benutzer nicht gefunden");
|
|
}
|
|
const userId = decodeURIComponent(match[1]);
|
|
const role = typeof (body && body.role) === "string" ? body.role : "";
|
|
if (!["admin", "approver", "operator"].includes(role)) {
|
|
return sendError(res, 400, "user.role.invalid", "Rolle ungueltig");
|
|
}
|
|
const user = runtime.users.find((entry) => entry.id === userId);
|
|
if (!user) {
|
|
return sendError(res, 404, "user.not_found", "Benutzer nicht gefunden");
|
|
}
|
|
if (config.adminEmails.has(user.email) && role !== "admin") {
|
|
return sendError(res, 409, "user.role.locked", "Konfigurierter Admin kann nicht herabgestuft werden");
|
|
}
|
|
|
|
user.role = role;
|
|
if (user.status !== "active") {
|
|
user.status = "active";
|
|
user.approvedAt = new Date().toISOString();
|
|
}
|
|
await writeJson(files.users, runtime.users);
|
|
await appendAudit("admin.user.role.update", actor, { userId, email: user.email, role });
|
|
return sendJson(res, 200, { ok: true, user: sanitizeUser(user) });
|
|
}
|
|
|
|
async function handleSelfAuthMethodUpdate(res, user, body) {
|
|
const primaryMethod = typeof (body && body.primaryMethod) === "string" ? body.primaryMethod : "";
|
|
if (!primaryMethod) {
|
|
return sendError(res, 400, "user.auth_methods.invalid", "primaryMethod erforderlich");
|
|
}
|
|
const availableIds = listPublicAuthMethods().map((entry) => entry.id);
|
|
if (!availableIds.includes(primaryMethod)) {
|
|
return sendError(res, 400, "user.auth_methods.invalid", "Methode nicht verfuegbar");
|
|
}
|
|
const enabledMethods = Array.isArray(user.enabledAuthMethods) ? user.enabledAuthMethods : [];
|
|
if (!enabledMethods.includes(primaryMethod)) {
|
|
return sendError(res, 400, "user.auth_methods.invalid", "Methode ist fuer Benutzer nicht freigeschaltet");
|
|
}
|
|
user.primaryAuthMethod = primaryMethod;
|
|
await writeJson(files.users, runtime.users);
|
|
await appendAudit("user.auth_method.update", user, { primaryMethod });
|
|
return sendJson(res, 200, { ok: true, user: sanitizeUser(user) });
|
|
}
|
|
|
|
async function handleSelfLanguageUpdate(res, user, body) {
|
|
const preferredLanguage = typeof (body && body.preferredLanguage) === "string"
|
|
? body.preferredLanguage.trim().toLowerCase()
|
|
: "";
|
|
if (!preferredLanguage) {
|
|
return sendError(res, 400, "user.language.invalid", "preferredLanguage erforderlich");
|
|
}
|
|
const allowed = new Set(["de", "en"]);
|
|
if (!allowed.has(preferredLanguage)) {
|
|
return sendError(res, 400, "user.language.invalid", "Sprache nicht verfuegbar");
|
|
}
|
|
user.preferredLanguage = preferredLanguage;
|
|
await writeJson(files.users, runtime.users);
|
|
await appendAudit("user.language.update", user, { preferredLanguage });
|
|
return sendJson(res, 200, { ok: true, user: sanitizeUser(user) });
|
|
}
|
|
|
|
async function handleAdminUserAuthMethodsUpdate(res, actor, pathname, body) {
|
|
const match = pathname.match(/^\/v1\/admin\/users\/([^/]+)\/auth-methods$/);
|
|
if (!match) {
|
|
return sendError(res, 404, "user.not_found", "Benutzer nicht gefunden");
|
|
}
|
|
const userId = decodeURIComponent(match[1]);
|
|
const user = runtime.users.find((entry) => entry.id === userId);
|
|
if (!user) {
|
|
return sendError(res, 404, "user.not_found", "Benutzer nicht gefunden");
|
|
}
|
|
|
|
const availableIds = listPublicAuthMethods().map((entry) => entry.id);
|
|
const enabledMethods = Array.isArray(body && body.enabledMethods)
|
|
? body.enabledMethods.filter((entry) => typeof entry === "string" && availableIds.includes(entry))
|
|
: [];
|
|
if (enabledMethods.length === 0) {
|
|
return sendError(res, 400, "user.auth_methods.invalid", "Mindestens eine Bestaetigungsart muss aktiv sein");
|
|
}
|
|
const primaryMethod = typeof (body && body.primaryMethod) === "string" && enabledMethods.includes(body.primaryMethod)
|
|
? body.primaryMethod
|
|
: enabledMethods[0];
|
|
|
|
user.enabledAuthMethods = Array.from(new Set(enabledMethods));
|
|
user.primaryAuthMethod = primaryMethod;
|
|
await writeJson(files.users, runtime.users);
|
|
await appendAudit("admin.user.auth_methods.update", actor, {
|
|
userId: user.id,
|
|
email: user.email,
|
|
enabledMethods: user.enabledAuthMethods,
|
|
primaryMethod: user.primaryAuthMethod
|
|
});
|
|
|
|
return sendJson(res, 200, { ok: true, user: sanitizeUser(user) });
|
|
}
|
|
|
|
async function handleApprovalDecision(res, actor, pathname) {
|
|
const match = pathname.match(/^\/v1\/approvals\/([^/]+)\/(approve|reject)$/);
|
|
if (!match) {
|
|
return sendError(res, 404, "approval.not_found", "Freigabe nicht gefunden");
|
|
}
|
|
const approvalId = decodeURIComponent(match[1]);
|
|
const action = match[2];
|
|
const approval = runtime.approvalRequests.find((entry) => entry.id === approvalId);
|
|
if (!approval) {
|
|
return sendError(res, 404, "approval.not_found", "Freigabe nicht gefunden");
|
|
}
|
|
const user = runtime.users.find((entry) => entry.id === approval.userId);
|
|
if (!user) {
|
|
return sendError(res, 404, "user.not_found", "Benutzer nicht gefunden");
|
|
}
|
|
|
|
approval.updatedAt = new Date().toISOString();
|
|
approval.updatedBy = actor.email;
|
|
approval.status = action === "approve" ? "approved" : "rejected";
|
|
approval.decidedAt = approval.updatedAt;
|
|
approval.decidedBy = actor.email;
|
|
|
|
if (action === "approve") {
|
|
user.status = "active";
|
|
user.approvedAt = approval.updatedAt;
|
|
user.deniedAt = null;
|
|
} else {
|
|
user.status = "denied";
|
|
user.deniedAt = approval.updatedAt;
|
|
}
|
|
|
|
await writeJson(files.users, runtime.users);
|
|
await saveApprovalRequests();
|
|
await appendAudit(`approval.${action}`, actor, { approvalId, userId: user.id, email: user.email });
|
|
broadcastEvent("approval.status.changed", { approvalId, status: approval.status, userId: user.id, email: user.email });
|
|
|
|
await sendEmailMessage(
|
|
user.email,
|
|
action === "approve" ? "ARCG Zugriff freigegeben" : "ARCG Zugriff abgelehnt",
|
|
action === "approve"
|
|
? "Dein Zugriff wurde freigegeben. Du kannst jetzt einen Login-Link anfordern."
|
|
: "Dein Zugriff wurde abgelehnt."
|
|
);
|
|
|
|
const approvalView = listApprovalsView().find((entry) => entry.id === approval.id) || approval;
|
|
return sendJson(res, 200, { ok: true, approval: approvalView, user: sanitizeUser(user) });
|
|
}
|
|
|
|
function listApprovalsView() {
|
|
return runtime.approvalRequests.map((approval) => {
|
|
const user = runtime.users.find((entry) => entry.id === approval.userId);
|
|
return {
|
|
...approval,
|
|
userStatus: user ? user.status : "unknown",
|
|
userRole: user ? user.role : "unknown"
|
|
};
|
|
});
|
|
}
|
|
|
|
async function handleMaintenanceUpdate(res, actor, body) {
|
|
const enabled = Boolean(body && body.enabled);
|
|
const message = typeof (body && body.message) === "string" && body.message.trim()
|
|
? body.message.trim()
|
|
: "Wartungsmodus aktiv. Login ist derzeit deaktiviert.";
|
|
|
|
runtime.systemState.maintenanceMode = enabled;
|
|
runtime.systemState.maintenanceMessage = message;
|
|
runtime.systemState.updatedAt = new Date().toISOString();
|
|
await saveSystemState();
|
|
await appendAudit("admin.maintenance.update", actor, { enabled, message });
|
|
|
|
if (enabled) {
|
|
await forceLogoutAllUsers();
|
|
broadcastEvent("system.maintenance.enabled", { message });
|
|
} else {
|
|
broadcastEvent("system.maintenance.disabled", { message });
|
|
}
|
|
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
maintenanceMode: runtime.systemState.maintenanceMode,
|
|
maintenanceMessage: runtime.systemState.maintenanceMessage,
|
|
branding: runtime.systemState.branding,
|
|
updatedAt: runtime.systemState.updatedAt
|
|
});
|
|
}
|
|
|
|
async function handleAdminBrandingLogoUpdate(res, actor, body) {
|
|
const theme = typeof (body && body.theme) === "string" ? body.theme.trim().toLowerCase() : "";
|
|
if (theme !== "light" && theme !== "dark") {
|
|
return sendError(res, 400, "branding.theme.invalid", "Theme muss 'light' oder 'dark' sein");
|
|
}
|
|
const dataUrl = typeof (body && body.dataUrl) === "string" ? body.dataUrl.trim() : "";
|
|
if (!dataUrl) {
|
|
return sendError(res, 400, "branding.data.missing", "Logo-Bild fehlt");
|
|
}
|
|
const parsed = parseImageDataUrl(dataUrl);
|
|
if (!parsed.ok) {
|
|
return sendError(res, 400, "branding.data.invalid", parsed.message || "Ungueltiges Bildformat");
|
|
}
|
|
if (parsed.buffer.length > 2 * 1024 * 1024) {
|
|
return sendError(res, 413, "branding.data.too_large", "Bild ist zu gross (max 2MB)");
|
|
}
|
|
|
|
const uploadsDir = config.brandingUploadsDir;
|
|
await fsp.mkdir(uploadsDir, { recursive: true });
|
|
const fileName = `logo-${theme}.${parsed.ext}`;
|
|
const filePath = path.join(uploadsDir, fileName);
|
|
await fsp.writeFile(filePath, parsed.buffer);
|
|
|
|
runtime.systemState = normalizeSystemState({
|
|
...runtime.systemState,
|
|
branding: {
|
|
...runtime.systemState.branding,
|
|
...(theme === "light" ? { logoLightUrl: `/uploads/${fileName}` } : { logoDarkUrl: `/uploads/${fileName}` })
|
|
},
|
|
updatedAt: new Date().toISOString()
|
|
});
|
|
await saveSystemState();
|
|
await appendAudit("admin.branding.logo.update", actor, {
|
|
theme,
|
|
fileName,
|
|
bytes: parsed.buffer.length
|
|
});
|
|
broadcastEvent("branding.updated", { theme, branding: runtime.systemState.branding });
|
|
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
branding: runtime.systemState.branding,
|
|
updatedAt: runtime.systemState.updatedAt
|
|
});
|
|
}
|
|
|
|
async function handleAdminBrandingLogoDelete(res, actor, theme) {
|
|
if (theme !== "light" && theme !== "dark") {
|
|
return sendError(res, 400, "branding.theme.invalid", "Theme muss 'light' oder 'dark' sein");
|
|
}
|
|
const key = theme === "light" ? "logoLightUrl" : "logoDarkUrl";
|
|
const currentUrl = runtime.systemState.branding && runtime.systemState.branding[key];
|
|
await removeBrandingFileIfExists(currentUrl);
|
|
|
|
runtime.systemState = normalizeSystemState({
|
|
...runtime.systemState,
|
|
branding: {
|
|
...runtime.systemState.branding,
|
|
[key]: null
|
|
},
|
|
updatedAt: new Date().toISOString()
|
|
});
|
|
await saveSystemState();
|
|
await appendAudit("admin.branding.logo.delete", actor, { theme });
|
|
broadcastEvent("branding.updated", { theme, branding: runtime.systemState.branding });
|
|
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
branding: runtime.systemState.branding,
|
|
updatedAt: runtime.systemState.updatedAt
|
|
});
|
|
}
|
|
|
|
async function removeBrandingFileIfExists(urlPath) {
|
|
if (!urlPath || typeof urlPath !== "string") {
|
|
return;
|
|
}
|
|
const clean = urlPath.split("?")[0];
|
|
if (!clean.startsWith("/uploads/")) {
|
|
return;
|
|
}
|
|
const publicDir = path.resolve(config.brandingUploadsDir);
|
|
const relativeUploadPath = clean.replace(/^\/uploads\//, "");
|
|
const safePath = path
|
|
.normalize(relativeUploadPath)
|
|
.replace(/^[/\\]+/, "")
|
|
.replace(/^([.][.][/\\])+/, "");
|
|
const fullPath = path.resolve(publicDir, safePath);
|
|
const resolvedPublicDir = path.resolve(publicDir);
|
|
if (fullPath !== resolvedPublicDir && !fullPath.startsWith(resolvedPublicDir + path.sep)) {
|
|
return;
|
|
}
|
|
try {
|
|
await fsp.unlink(fullPath);
|
|
} catch {
|
|
// ignore missing file
|
|
}
|
|
}
|
|
|
|
async function reconcileBrandingFromUploads() {
|
|
const light = runtime.systemState.branding && runtime.systemState.branding.logoLightUrl
|
|
? runtime.systemState.branding.logoLightUrl
|
|
: await findExistingLogoUrl("light");
|
|
const dark = runtime.systemState.branding && runtime.systemState.branding.logoDarkUrl
|
|
? runtime.systemState.branding.logoDarkUrl
|
|
: await findExistingLogoUrl("dark");
|
|
|
|
if (light === runtime.systemState.branding.logoLightUrl && dark === runtime.systemState.branding.logoDarkUrl) {
|
|
return;
|
|
}
|
|
|
|
runtime.systemState = normalizeSystemState({
|
|
...runtime.systemState,
|
|
branding: {
|
|
...runtime.systemState.branding,
|
|
logoLightUrl: light,
|
|
logoDarkUrl: dark
|
|
},
|
|
updatedAt: runtime.systemState.updatedAt || new Date().toISOString()
|
|
});
|
|
await saveSystemState();
|
|
}
|
|
|
|
async function findExistingLogoUrl(theme) {
|
|
const exts = ["png", "jpg", "jpeg", "svg", "webp"];
|
|
for (const ext of exts) {
|
|
const fileName = `logo-${theme}.${ext}`;
|
|
const fullPath = path.join(config.brandingUploadsDir, fileName);
|
|
try {
|
|
await fsp.access(fullPath, fs.constants.R_OK);
|
|
return `/uploads/${fileName}`;
|
|
} catch {
|
|
// try next extension
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseImageDataUrl(value) {
|
|
const match = /^data:(image\/(png|jpeg|jpg|svg\+xml|webp));base64,([A-Za-z0-9+/=\s]+)$/i.exec(value);
|
|
if (!match) {
|
|
return { ok: false, message: "Nur PNG/JPEG/SVG/WEBP als Data-URL erlaubt" };
|
|
}
|
|
const mime = match[1].toLowerCase();
|
|
const extMap = {
|
|
"image/png": "png",
|
|
"image/jpeg": "jpg",
|
|
"image/jpg": "jpg",
|
|
"image/svg+xml": "svg",
|
|
"image/webp": "webp"
|
|
};
|
|
const ext = extMap[mime];
|
|
if (!ext) {
|
|
return { ok: false, message: "Dateiformat nicht unterstuetzt" };
|
|
}
|
|
try {
|
|
const base64 = match[3].replace(/\s+/g, "");
|
|
const buffer = Buffer.from(base64, "base64");
|
|
if (!buffer.length) {
|
|
return { ok: false, message: "Leeres Bild" };
|
|
}
|
|
return { ok: true, mime, ext, buffer };
|
|
} catch {
|
|
return { ok: false, message: "Bilddaten konnten nicht gelesen werden" };
|
|
}
|
|
}
|
|
|
|
function normalizeSystemState(state) {
|
|
const input = state && typeof state === "object" ? state : {};
|
|
const branding = input.branding && typeof input.branding === "object" ? input.branding : {};
|
|
return {
|
|
maintenanceMode: Boolean(input.maintenanceMode),
|
|
maintenanceMessage: typeof input.maintenanceMessage === "string" && input.maintenanceMessage.trim()
|
|
? input.maintenanceMessage
|
|
: "Wartungsmodus aktiv. Login ist derzeit deaktiviert.",
|
|
branding: {
|
|
logoLightUrl: typeof branding.logoLightUrl === "string" && branding.logoLightUrl.trim() ? branding.logoLightUrl : null,
|
|
logoDarkUrl: typeof branding.logoDarkUrl === "string" && branding.logoDarkUrl.trim() ? branding.logoDarkUrl : null
|
|
},
|
|
updatedAt: input.updatedAt || null
|
|
};
|
|
}
|
|
|
|
async function migratePluginSettingsDefaults() {
|
|
runtime.pluginState.settings = runtime.pluginState.settings && typeof runtime.pluginState.settings === "object"
|
|
? runtime.pluginState.settings
|
|
: {};
|
|
|
|
const dataDir = config.dataDir;
|
|
let changed = false;
|
|
changed = ensureVswrNativeSettings(dataDir) || changed;
|
|
changed = ensureVswrNanoVnaSettings(dataDir) || changed;
|
|
changed = ensureVswrReportReaderSettings(dataDir) || changed;
|
|
changed = ensureOpenWebRxBandmapSettings() || changed;
|
|
changed = ensureMicrohamSettings() || changed;
|
|
changed = ensureOauthSettings() || changed;
|
|
|
|
if (changed) {
|
|
await savePluginState();
|
|
}
|
|
}
|
|
|
|
function ensureOauthSettings() {
|
|
const pluginId = "rms.auth.oauth";
|
|
const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object"
|
|
? { ...runtime.pluginState.settings[pluginId] }
|
|
: {};
|
|
const next = {
|
|
...current,
|
|
authorizeUrl: current.authorizeUrl || "https://accounts.google.com/o/oauth2/v2/auth",
|
|
tokenUrl: current.tokenUrl || "https://oauth2.googleapis.com/token",
|
|
userInfoUrl: current.userInfoUrl || "https://openidconnect.googleapis.com/v1/userinfo",
|
|
clientId: current.clientId || "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com",
|
|
clientSecret: current.clientSecret || "YOUR_GOOGLE_CLIENT_SECRET",
|
|
scope: current.scope || "openid email profile",
|
|
redirectUri: current.redirectUri || "",
|
|
emailField: current.emailField || "email",
|
|
authStyle: current.authStyle || "body",
|
|
audience: current.audience || "",
|
|
extraAuthorizeParams: current.extraAuthorizeParams || "",
|
|
extraTokenParams: current.extraTokenParams || ""
|
|
};
|
|
if (JSON.stringify(current) !== JSON.stringify(next)) {
|
|
runtime.pluginState.settings[pluginId] = next;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function ensureOpenWebRxBandmapSettings() {
|
|
const pluginId = "rms.openwebrx.bandmap";
|
|
const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object"
|
|
? { ...runtime.pluginState.settings[pluginId] }
|
|
: {};
|
|
const next = { ...current };
|
|
|
|
const syncFromEnv = boolFromEnv("OPENWEBRX_BANDMAP_SYNC_SETTINGS_FROM_ENV", config.execMode === "prod");
|
|
if (syncFromEnv) {
|
|
next.csvPath = String(process.env.OPENWEBRX_BANDMAP_CSV_PATH || "").trim();
|
|
next.configFilePath = String(process.env.OPENWEBRX_CONFIG_PATH || "").trim();
|
|
next.setCommandTemplate = String(process.env.OPENWEBRX_BAND_SET_CMD_TEMPLATE || "").trim();
|
|
next.stateFilePath = String(process.env.OPENWEBRX_BAND_STATE_PATH || "").trim();
|
|
next.timeoutMs = clampInteger(Number(process.env.OPENWEBRX_BAND_TIMEOUT_MS || 20000), 1000, 120000, 20000);
|
|
} else if (!Number.isFinite(Number(next.timeoutMs)) || Number(next.timeoutMs) < 1000) {
|
|
next.timeoutMs = 20000;
|
|
}
|
|
|
|
if (JSON.stringify(current) !== JSON.stringify(next)) {
|
|
runtime.pluginState.settings[pluginId] = next;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function ensureVswrNativeSettings(dataDir) {
|
|
const pluginId = "rms.vswr.native";
|
|
const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object"
|
|
? { ...runtime.pluginState.settings[pluginId] }
|
|
: {};
|
|
const next = { ...current };
|
|
|
|
const defaultReportPath = String(process.env.VSWR_REPORT_JSON_PATH || path.join(dataDir, "vswr", "swr-report.json"));
|
|
const defaultOutputBase = String(process.env.VSWR_OUTPUT_BASE_DIR || path.join(dataDir, "vswr", "output"));
|
|
const defaultCheckCommand = String(process.env.VSWR_CHECK_CMD || "/opt/remotestation/bin/vswr-check.sh").trim();
|
|
const defaultCommand = String(
|
|
process.env.NANOVNA_COMMAND_TEMPLATE
|
|
|| process.env.VSWR_CHECK_CMD
|
|
|| `${defaultCheckCommand} {band} {startHz} {endHz} {bandDir}`
|
|
);
|
|
|
|
next.reportJsonPath = normalizeVswrPath(next.reportJsonPath, dataDir, defaultReportPath);
|
|
next.outputBaseDir = normalizeVswrPath(next.outputBaseDir, dataDir, defaultOutputBase);
|
|
if (!String(next.nanovnaCommandTemplate || "").trim()) {
|
|
next.nanovnaCommandTemplate = defaultCommand;
|
|
}
|
|
if (String(next.nanovnaCommandTemplate || "").includes("NanoVNASaver.py")) {
|
|
next.nanovnaCommandTemplate = defaultCheckCommand;
|
|
}
|
|
if (!Number.isFinite(Number(next.timeoutMsPerBand)) || Number(next.timeoutMsPerBand) < 1000) {
|
|
next.timeoutMsPerBand = Number(process.env.VSWR_TIMEOUT_MS_PER_BAND || 90000);
|
|
}
|
|
if (!String(next.publicImagesBaseUrl || "").trim()) {
|
|
next.publicImagesBaseUrl = String(process.env.VSWR_IMAGES_BASE_URL || deriveVswrImagesBaseUrl());
|
|
}
|
|
|
|
if (JSON.stringify(current) !== JSON.stringify(next)) {
|
|
runtime.pluginState.settings[pluginId] = next;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function ensureVswrNanoVnaSettings(dataDir) {
|
|
const pluginId = "rms.vswr.nanovna";
|
|
const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object"
|
|
? { ...runtime.pluginState.settings[pluginId] }
|
|
: {};
|
|
const next = { ...current };
|
|
|
|
const defaultMeta = String(process.env.VSWR_METADATA_PATH || path.join(dataDir, "vswr", "metadata.txt"));
|
|
next.metadataPath = normalizeVswrPath(next.metadataPath, dataDir, defaultMeta);
|
|
if (!String(next.checkCommand || "").trim()) {
|
|
next.checkCommand = String(process.env.VSWR_CHECK_CMD || "/opt/remotestation/bin/vswr-check.sh");
|
|
}
|
|
if (!Number.isFinite(Number(next.timeoutMs)) || Number(next.timeoutMs) < 1000) {
|
|
next.timeoutMs = Number(process.env.VSWR_CHECK_TIMEOUT_MS || 240000);
|
|
}
|
|
if (!Number.isFinite(Number(next.expectedDurationMs)) || Number(next.expectedDurationMs) < 1000) {
|
|
next.expectedDurationMs = Number(process.env.SWR_CHECK_DURATION_MS || 54000);
|
|
}
|
|
|
|
if (JSON.stringify(current) !== JSON.stringify(next)) {
|
|
runtime.pluginState.settings[pluginId] = next;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function ensureVswrReportReaderSettings(dataDir) {
|
|
const pluginId = "rms.vswr.report_reader";
|
|
const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object"
|
|
? { ...runtime.pluginState.settings[pluginId] }
|
|
: {};
|
|
const next = { ...current };
|
|
|
|
const defaultHtml = String(process.env.VSWR_OVERVIEW_HTML_PATH || path.join(dataDir, "vswr", "index.html"));
|
|
const defaultMeta = String(process.env.VSWR_METADATA_PATH || path.join(dataDir, "vswr", "metadata.txt"));
|
|
const defaultImages = String(process.env.VSWR_IMAGES_DIR_PATH || path.join(dataDir, "vswr", "images"));
|
|
|
|
next.overviewHtmlPath = normalizeVswrPath(next.overviewHtmlPath, dataDir, defaultHtml);
|
|
next.metadataPath = normalizeVswrPath(next.metadataPath, dataDir, defaultMeta);
|
|
next.imagesDirPath = normalizeVswrPath(next.imagesDirPath, dataDir, defaultImages);
|
|
if (!String(next.publicImagesBaseUrl || "").trim()) {
|
|
next.publicImagesBaseUrl = String(process.env.VSWR_IMAGES_BASE_URL || deriveVswrImagesBaseUrl());
|
|
}
|
|
|
|
if (JSON.stringify(current) !== JSON.stringify(next)) {
|
|
runtime.pluginState.settings[pluginId] = next;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function ensureMicrohamSettings() {
|
|
const pluginId = "rms.microham";
|
|
const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object"
|
|
? { ...runtime.pluginState.settings[pluginId] }
|
|
: {};
|
|
const next = { ...current };
|
|
|
|
const syncFromEnv = boolFromEnv("MICROHAM_SYNC_SETTINGS_FROM_ENV", config.execMode === "prod");
|
|
if (!syncFromEnv) {
|
|
return false;
|
|
}
|
|
|
|
next.device = String(process.env.MICROHAM_DEVICE || process.env.RMS_MICROHAM_DEV || "/dev/rms-microham-u3").trim() || "/dev/rms-microham-u3";
|
|
next.pttCommandsEnabled = boolFromEnv("MICROHAM_PTT_COMMANDS_ENABLED", true);
|
|
next.pttDownCommand = String(process.env.MICROHAM_PTT_DOWN_CMD || "").trim();
|
|
next.pttUpCommand = String(process.env.MICROHAM_PTT_UP_CMD || "").trim();
|
|
next.pttTimeoutMs = clampInteger(Number(process.env.MICROHAM_PTT_TIMEOUT_MS || 5000), 1000, 60000, 5000);
|
|
next.pttApplyBandState = boolFromEnv("MICROHAM_PTT_APPLY_BAND_STATE", true);
|
|
next.pttRigctlModel = String(process.env.MICROHAM_PTT_RIGCTL_MODEL || "3023").trim() || "3023";
|
|
next.pttRigctlBaud = String(process.env.MICROHAM_PTT_RIGCTL_BAUD || "19200").trim() || "19200";
|
|
next.pttRigctlSetConf = String(process.env.MICROHAM_PTT_RIGCTL_SETCONF || "rts_state=OFF,dtr_state=OFF").trim() || "rts_state=OFF,dtr_state=OFF";
|
|
next.audioEnabled = boolFromEnv("MICROHAM_AUDIO_ENABLED", true);
|
|
next.audioAlsaDevice = String(process.env.MICROHAM_AUDIO_ALSA_DEVICE || "plughw:CARD=CODEC,DEV=0").trim() || "plughw:CARD=CODEC,DEV=0";
|
|
next.audioInputMime = String(process.env.MICROHAM_AUDIO_INPUT_MIME || "webm").trim().toLowerCase() === "ogg" ? "ogg" : "webm";
|
|
next.audioStopOnDisconnect = boolFromEnv("MICROHAM_AUDIO_STOP_ON_DISCONNECT", true);
|
|
next.audioChunkMs = clampInteger(Number(process.env.MICROHAM_AUDIO_CHUNK_MS || 100), 40, 2000, 100);
|
|
next.audioSessionTimeoutMs = clampInteger(Number(process.env.MICROHAM_AUDIO_SESSION_TIMEOUT_MS || 120000), 1000, 3600000, 120000);
|
|
next.audioFfmpegPath = String(process.env.MICROHAM_AUDIO_FFMPEG_PATH || "").trim();
|
|
if (next.audioFfmpegExtraArgs === undefined || next.audioFfmpegExtraArgs === null) {
|
|
next.audioFfmpegExtraArgs = String(process.env.MICROHAM_AUDIO_FFMPEG_EXTRA_ARGS || "").trim();
|
|
}
|
|
|
|
if (JSON.stringify(current) !== JSON.stringify(next)) {
|
|
runtime.pluginState.settings[pluginId] = next;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function boolFromEnv(name, fallback) {
|
|
const raw = String(process.env[name] ?? "").trim().toLowerCase();
|
|
if (!raw) {
|
|
return Boolean(fallback);
|
|
}
|
|
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
}
|
|
|
|
function clampInteger(value, min, max, fallback) {
|
|
if (!Number.isFinite(Number(value))) {
|
|
return fallback;
|
|
}
|
|
return Math.max(min, Math.min(max, Math.trunc(Number(value))));
|
|
}
|
|
|
|
function normalizeVswrPath(value, dataDir, fallback) {
|
|
const raw = String(value || "").trim();
|
|
if (!raw) {
|
|
return String(fallback || "");
|
|
}
|
|
if (path.isAbsolute(raw)) {
|
|
return raw;
|
|
}
|
|
const cleaned = raw.replace(/^\.\//, "");
|
|
if (cleaned.startsWith("data/")) {
|
|
return path.join(dataDir, cleaned.slice("data/".length));
|
|
}
|
|
return path.join(dataDir, cleaned);
|
|
}
|
|
|
|
function deriveVswrImagesBaseUrl() {
|
|
const overview = String(process.env.SWR_OVERVIEW_URL || "").trim();
|
|
if (!overview) {
|
|
return "";
|
|
}
|
|
return `${overview.replace(/\/$/, "")}/images`;
|
|
}
|
|
|
|
async function forceLogoutAllUsers() {
|
|
for (const user of runtime.users) {
|
|
if (user.role === "admin") {
|
|
continue;
|
|
}
|
|
runtime.authState.tokenVersionByUser[user.id] = (runtime.authState.tokenVersionByUser[user.id] || 0) + 1;
|
|
}
|
|
for (const token of runtime.authState.refreshTokens) {
|
|
const tokenUser = runtime.users.find((user) => user.id === token.userId);
|
|
if (tokenUser && tokenUser.role === "admin") {
|
|
continue;
|
|
}
|
|
if (!token.revokedAt) {
|
|
token.revokedAt = new Date().toISOString();
|
|
}
|
|
}
|
|
await saveAuthState();
|
|
}
|
|
|
|
function listPublicAuthMethods() {
|
|
const methods = [];
|
|
for (const plugin of runtime.plugins.values()) {
|
|
const def = plugin && plugin.manifest && plugin.manifest.authMethod;
|
|
if (!def || !def.id || !def.type) {
|
|
continue;
|
|
}
|
|
if (!runtime.pluginState.enabled[plugin.manifest.id]) {
|
|
continue;
|
|
}
|
|
methods.push({
|
|
id: String(def.id),
|
|
type: String(def.type),
|
|
label: String(def.label || def.id),
|
|
pluginId: plugin.manifest.id
|
|
});
|
|
}
|
|
methods.sort((a, b) => {
|
|
if (a.id === "smtp-link" && b.id !== "smtp-link") return -1;
|
|
if (b.id === "smtp-link" && a.id !== "smtp-link") return 1;
|
|
return a.id.localeCompare(b.id);
|
|
});
|
|
return methods;
|
|
}
|
|
|
|
function preferredAuthMethodId(methods) {
|
|
const list = Array.isArray(methods) ? methods : [];
|
|
if (list.some((entry) => entry.id === "smtp-link")) {
|
|
return "smtp-link";
|
|
}
|
|
return list[0] ? list[0].id : null;
|
|
}
|
|
|
|
function resolveAuthMethodForUser(user, requestedMethodId) {
|
|
const all = listPublicAuthMethods();
|
|
if (requestedMethodId) {
|
|
const requestedPublic = all.find((entry) => entry.id === requestedMethodId) || null;
|
|
if (requestedPublic && requestedPublic.type === "otp") {
|
|
return requestedPublic;
|
|
}
|
|
}
|
|
const enabledSet = new Set(Array.isArray(user.enabledAuthMethods) ? user.enabledAuthMethods : []);
|
|
const allowed = all.filter((entry) => enabledSet.has(entry.id));
|
|
if (allowed.length === 0) {
|
|
return null;
|
|
}
|
|
if (requestedMethodId) {
|
|
return allowed.find((entry) => entry.id === requestedMethodId) || null;
|
|
}
|
|
if (user.primaryAuthMethod) {
|
|
const primary = allowed.find((entry) => entry.id === user.primaryAuthMethod);
|
|
if (primary) return primary;
|
|
}
|
|
return allowed[0] || null;
|
|
}
|
|
|
|
function findPublicAuthMethodById(methodId) {
|
|
const needle = String(methodId || "").trim();
|
|
if (!needle) {
|
|
return null;
|
|
}
|
|
return listPublicAuthMethods().find((entry) => entry.id === needle) || null;
|
|
}
|
|
|
|
async function issueOauthChallenge(userId, purpose, methodId) {
|
|
const state = crypto.randomBytes(24).toString("base64url");
|
|
runtime.authState.oauthChallenges.push({
|
|
id: crypto.randomUUID(),
|
|
userId,
|
|
purpose,
|
|
methodId,
|
|
state,
|
|
createdAt: new Date().toISOString(),
|
|
expiresAtMs: Date.now() + 10 * 60 * 1000,
|
|
consumedAt: null
|
|
});
|
|
pruneOauthChallenges();
|
|
await saveAuthState();
|
|
return state;
|
|
}
|
|
|
|
function consumeOauthChallenge(state) {
|
|
const token = String(state || "").trim();
|
|
if (!token) {
|
|
return { ok: false, message: "OAuth state fehlt" };
|
|
}
|
|
const now = Date.now();
|
|
const record = runtime.authState.oauthChallenges
|
|
.filter((entry) => !entry.consumedAt && now <= Number(entry.expiresAtMs || 0))
|
|
.find((entry) => safeEquals(String(entry.state || ""), token));
|
|
if (!record) {
|
|
return { ok: false, message: "OAuth state ungueltig oder abgelaufen" };
|
|
}
|
|
record.consumedAt = new Date().toISOString();
|
|
saveAuthState().catch(() => {});
|
|
return {
|
|
ok: true,
|
|
userId: record.userId,
|
|
purpose: record.purpose,
|
|
methodId: record.methodId,
|
|
state: record.state
|
|
};
|
|
}
|
|
|
|
function pruneOauthChallenges() {
|
|
const now = Date.now();
|
|
runtime.authState.oauthChallenges = runtime.authState.oauthChallenges.filter((entry) => {
|
|
if (entry.consumedAt && now - new Date(entry.consumedAt).getTime() > 24 * 60 * 60 * 1000) {
|
|
return false;
|
|
}
|
|
return Number(entry.expiresAtMs || 0) > now - 24 * 60 * 60 * 1000;
|
|
});
|
|
}
|
|
|
|
async function finalizeLoginForVerification(req, user, purpose, via) {
|
|
if (purpose === "verify") {
|
|
user.emailVerifiedAt = new Date().toISOString();
|
|
if (user.accountType === "primary-domain" || user.role === "admin") {
|
|
user.status = "active";
|
|
user.approvedAt = new Date().toISOString();
|
|
user.deniedAt = null;
|
|
await writeJson(files.users, runtime.users);
|
|
await appendAudit("auth.verified_auto_approved", user, null);
|
|
} else {
|
|
user.status = "pending_approval";
|
|
user.deniedAt = null;
|
|
await writeJson(files.users, runtime.users);
|
|
await createApprovalRequest(user, req);
|
|
await appendAudit("auth.verified_pending_approval", user, null);
|
|
return {
|
|
ok: true,
|
|
verified: true,
|
|
approved: false,
|
|
message: "E-Mail bestaetigt. Kein direkter Zugriff fuer diese Domain. Freigabe wurde angefordert."
|
|
};
|
|
}
|
|
}
|
|
|
|
if (runtime.systemState.maintenanceMode && user.role !== "admin") {
|
|
return { error: { status: 503, code: "auth.maintenance", message: runtime.systemState.maintenanceMessage } };
|
|
}
|
|
if (user.status !== "active") {
|
|
return { error: { status: 403, code: "auth.not_approved", message: "Benutzer ist noch nicht freigegeben" } };
|
|
}
|
|
|
|
const tokens = await issueTokenPair(user, req);
|
|
await appendAudit("auth.login", user, { sid: tokens.sid, via: via || purpose });
|
|
return {
|
|
ok: true,
|
|
user: sanitizeUser(user),
|
|
accessToken: tokens.accessToken,
|
|
refreshToken: tokens.refreshToken,
|
|
expiresInSec: config.accessTokenTtlSec
|
|
};
|
|
}
|
|
|
|
function redirectToLoginWithAuthError(res, code, message) {
|
|
const params = new URLSearchParams();
|
|
params.set("authError", String(code || "auth.oauth_failed"));
|
|
if (message) {
|
|
params.set("authMessage", String(message));
|
|
}
|
|
res.writeHead(302, { Location: `/login?${params.toString()}` });
|
|
res.end();
|
|
}
|
|
|
|
async function dispatchAuthChallenge(req, user, method, payload) {
|
|
const plugin = runtime.plugins.get(method.pluginId);
|
|
if (!plugin || typeof plugin.instance.execute !== "function") {
|
|
throw new Error(`Auth method plugin missing: ${method.id}`);
|
|
}
|
|
|
|
await appendMailOutboxEntry({
|
|
at: new Date().toISOString(),
|
|
via: method.pluginId,
|
|
to: user.email,
|
|
subject: String(payload && payload.subject ? payload.subject : ""),
|
|
text: String(payload && payload.text ? payload.text : ""),
|
|
html: String(payload && payload.html ? payload.html : ""),
|
|
challengeType: String(payload && payload.type ? payload.type : method.type || "unknown"),
|
|
source: "server-prelog"
|
|
});
|
|
|
|
await plugin.instance.execute("send_challenge", {
|
|
methodId: method.id,
|
|
methodType: method.type,
|
|
user: sanitizeUser(user),
|
|
recipient: user.email,
|
|
origin: publicBaseUrlFor(req),
|
|
payload
|
|
}, { user });
|
|
}
|
|
|
|
async function issueOtpChallenge(userId, purpose) {
|
|
const code = String(Math.floor(100000 + Math.random() * 900000));
|
|
runtime.authState.otpChallenges.push({
|
|
id: crypto.randomUUID(),
|
|
userId,
|
|
purpose,
|
|
codeHash: sha256(code),
|
|
createdAt: new Date().toISOString(),
|
|
expiresAtMs: Date.now() + 10 * 60 * 1000,
|
|
consumedAt: null,
|
|
failedAttempts: 0
|
|
});
|
|
pruneOtpChallenges();
|
|
await saveAuthState();
|
|
return code;
|
|
}
|
|
|
|
function consumeOtpChallenge(userId, code) {
|
|
const now = Date.now();
|
|
const record = runtime.authState.otpChallenges
|
|
.filter((entry) => entry.userId === userId && !entry.consumedAt && now <= entry.expiresAtMs)
|
|
.sort((a, b) => b.expiresAtMs - a.expiresAtMs)[0];
|
|
if (!record) {
|
|
return { ok: false, message: "Kein gueltiger OTP-Code" };
|
|
}
|
|
if (!safeEquals(record.codeHash, sha256(code))) {
|
|
record.failedAttempts = Number(record.failedAttempts || 0) + 1;
|
|
if (record.failedAttempts >= 5) {
|
|
record.consumedAt = new Date().toISOString();
|
|
}
|
|
saveAuthState().catch(() => {});
|
|
return { ok: false, message: "OTP-Code ungueltig" };
|
|
}
|
|
record.consumedAt = new Date().toISOString();
|
|
saveAuthState().catch(() => {});
|
|
return { ok: true, userId, purpose: record.purpose };
|
|
}
|
|
|
|
function pruneOtpChallenges() {
|
|
const now = Date.now();
|
|
runtime.authState.otpChallenges = runtime.authState.otpChallenges.filter((entry) => {
|
|
if (entry.consumedAt && now - new Date(entry.consumedAt).getTime() > 24 * 60 * 60 * 1000) {
|
|
return false;
|
|
}
|
|
return entry.expiresAtMs > now - 24 * 60 * 60 * 1000;
|
|
});
|
|
}
|
|
|
|
async function createApprovalRequest(user, req) {
|
|
const existing = runtime.approvalRequests.find((entry) => entry.userId === user.id && entry.status === "pending");
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
const approval = {
|
|
id: `apr_${crypto.randomUUID()}`,
|
|
userId: user.id,
|
|
email: user.email,
|
|
status: "pending",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
updatedBy: user.email,
|
|
decidedAt: null,
|
|
decidedBy: null,
|
|
notes: `Externes Domain-Konto (${domainForEmail(user.email)})`
|
|
};
|
|
runtime.approvalRequests.push(approval);
|
|
await saveApprovalRequests();
|
|
|
|
const recipients = runtime.users
|
|
.filter((entry) => entry.status === "active" && (entry.role === "admin" || entry.role === "approver"))
|
|
.map((entry) => entry.email);
|
|
const approvalsUrl = `${publicBaseUrlFor(req)}/rms/freigaben`;
|
|
for (const recipient of recipients) {
|
|
await sendEmailMessage(
|
|
recipient,
|
|
"Neue Freigabe-Anfrage",
|
|
`Neue Freigabe fuer ${user.email}. Bitte pruefen: ${approvalsUrl}`
|
|
);
|
|
}
|
|
return approval;
|
|
}
|
|
|
|
async function issueEmailToken(userId, purpose) {
|
|
const token = crypto.randomBytes(32).toString("base64url");
|
|
const ttlMs = purpose === "login" ? 15 * 60 * 1000 : 24 * 60 * 60 * 1000;
|
|
runtime.authState.emailTokens.push({
|
|
tokenHash: sha256(token),
|
|
userId,
|
|
purpose,
|
|
createdAt: new Date().toISOString(),
|
|
expiresAtMs: Date.now() + ttlMs,
|
|
consumedAt: null
|
|
});
|
|
pruneEmailTokens();
|
|
await saveAuthState();
|
|
return token;
|
|
}
|
|
|
|
function consumeEmailToken(rawToken) {
|
|
const tokenHash = sha256(rawToken);
|
|
const entry = runtime.authState.emailTokens.find((token) => token.tokenHash === tokenHash);
|
|
if (!entry) {
|
|
return { ok: false, message: "Token nicht gefunden" };
|
|
}
|
|
if (entry.consumedAt) {
|
|
return { ok: false, message: "Token bereits verwendet" };
|
|
}
|
|
if (Date.now() > entry.expiresAtMs) {
|
|
return { ok: false, message: "Token abgelaufen" };
|
|
}
|
|
entry.consumedAt = new Date().toISOString();
|
|
saveAuthState().catch(() => {});
|
|
return { ok: true, userId: entry.userId, purpose: entry.purpose };
|
|
}
|
|
|
|
function pruneEmailTokens() {
|
|
const now = Date.now();
|
|
runtime.authState.emailTokens = runtime.authState.emailTokens.filter((entry) => {
|
|
if (entry.consumedAt && now - new Date(entry.consumedAt).getTime() > 24 * 60 * 60 * 1000) {
|
|
return false;
|
|
}
|
|
return entry.expiresAtMs > now - 24 * 60 * 60 * 1000;
|
|
});
|
|
}
|
|
|
|
function publicBaseUrlFor(req) {
|
|
if (config.publicBaseUrl) {
|
|
return config.publicBaseUrl.replace(/\/+$/, "");
|
|
}
|
|
const proto = String(req.headers["x-forwarded-proto"] || "http").split(",")[0].trim();
|
|
const host = String(req.headers["x-forwarded-host"] || req.headers.host || `localhost:${config.port}`).split(",")[0].trim();
|
|
return `${proto}://${host}`;
|
|
}
|
|
|
|
function serializeMailOutboxEntry(entry) {
|
|
return `${JSON.stringify(entry)}\n`;
|
|
}
|
|
|
|
async function appendMailOutboxEntry(entry) {
|
|
const line = serializeMailOutboxEntry(entry);
|
|
await storage.appendText(files.mailOutbox, line);
|
|
|
|
// With SQLite storage, logs are persisted in DB tables.
|
|
// Mirror mail outbox additionally to a plain file for ops visibility.
|
|
if (storage && storage.id === "sqlite") {
|
|
await fsp.mkdir(path.dirname(files.mailOutbox), { recursive: true });
|
|
await fsp.appendFile(files.mailOutbox, line, "utf8");
|
|
}
|
|
|
|
// In dev mode, always mirror into local ./data/mail-outbox.log
|
|
// so developers can inspect logs independent of DATA_DIR overrides.
|
|
if (config.execMode === "dev") {
|
|
const localMirror = path.join(rootDir, "data", "mail-outbox.log");
|
|
if (localMirror !== files.mailOutbox) {
|
|
await fsp.mkdir(path.dirname(localMirror), { recursive: true });
|
|
await fsp.appendFile(localMirror, line, "utf8");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function ensureMailOutboxInitialized() {
|
|
if (!(await storage.exists(files.mailOutbox))) {
|
|
await storage.writeText(files.mailOutbox, "");
|
|
}
|
|
if (storage && storage.id === "sqlite" && !fs.existsSync(files.mailOutbox)) {
|
|
await fsp.mkdir(path.dirname(files.mailOutbox), { recursive: true });
|
|
await fsp.writeFile(files.mailOutbox, "", "utf8");
|
|
}
|
|
if (config.execMode === "dev") {
|
|
const localMirror = path.join(rootDir, "data", "mail-outbox.log");
|
|
if (!fs.existsSync(localMirror)) {
|
|
await fsp.mkdir(path.dirname(localMirror), { recursive: true });
|
|
await fsp.writeFile(localMirror, "", "utf8");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function sendEmailMessage(to, subject, text, html = "") {
|
|
const smtpMethod = listPublicAuthMethods().find((entry) => entry.id === "smtp-link");
|
|
if (smtpMethod) {
|
|
const plugin = runtime.plugins.get(smtpMethod.pluginId);
|
|
if (plugin && typeof plugin.instance.execute === "function") {
|
|
await plugin.instance.execute("send_challenge", {
|
|
methodId: smtpMethod.id,
|
|
methodType: smtpMethod.type,
|
|
user: null,
|
|
recipient: to,
|
|
origin: config.publicBaseUrl || "",
|
|
payload: {
|
|
type: "link",
|
|
subject,
|
|
text,
|
|
html: String(html || "")
|
|
}
|
|
}, { user: null });
|
|
return;
|
|
}
|
|
}
|
|
const entry = {
|
|
at: new Date().toISOString(),
|
|
to,
|
|
subject,
|
|
text,
|
|
html: String(html || "")
|
|
};
|
|
await appendMailOutboxEntry(entry);
|
|
}
|
|
|
|
function buildAuthEmailMessage(req, options) {
|
|
const subject = String(options && options.subject ? options.subject : "ARCG Login");
|
|
const text = String(options && options.text ? options.text : "");
|
|
const user = options && options.user ? options.user : null;
|
|
const actionLink = options && options.actionLink ? String(options.actionLink) : "";
|
|
const actionLabel = options && options.actionLabel ? String(options.actionLabel) : "Aktion ausfuehren";
|
|
const code = options && options.code ? String(options.code) : "";
|
|
const baseUrl = publicBaseUrlFor(req);
|
|
const branding = runtime.systemState && runtime.systemState.branding ? runtime.systemState.branding : {};
|
|
const logoCandidate = branding.logoLightUrl || branding.logoDarkUrl || "";
|
|
const logoUrl = toAbsoluteUrl(baseUrl, logoCandidate);
|
|
const stationName = config.stationName || "ARCG RemoteStation";
|
|
const safeSubject = escapeHtml(subject);
|
|
const safeStationName = escapeHtml(stationName);
|
|
const safeTextHtml = escapeHtml(text).replace(/\n/g, "<br>");
|
|
const safeActionLabel = escapeHtml(actionLabel);
|
|
const safeActionLink = escapeHtml(actionLink);
|
|
const safeCode = escapeHtml(code);
|
|
|
|
const html = [
|
|
"<!doctype html>",
|
|
'<html lang="de">',
|
|
'<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' + safeSubject + "</title></head>",
|
|
'<body style="margin:0;padding:0;background:#f5f7fb;color:#10253f;font-family:Arial,Helvetica,sans-serif;">',
|
|
'<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="padding:28px 12px;">',
|
|
'<tr><td align="center">',
|
|
'<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:620px;background:#ffffff;border:1px solid #e3e8ef;border-radius:14px;overflow:hidden;">',
|
|
'<tr><td style="padding:20px 24px;border-bottom:1px solid #edf1f6;">',
|
|
(logoUrl ? '<img src="' + escapeHtml(logoUrl) + '" alt="Logo" style="max-height:54px;max-width:220px;display:block;margin:0 0 12px 0;">' : ""),
|
|
'<div style="font-size:12px;color:#5d6f85;letter-spacing:0.06em;text-transform:uppercase;">ARCG RemoteStation</div>',
|
|
'<h1 style="margin:6px 0 0 0;font-size:22px;line-height:1.3;color:#10253f;">' + safeSubject + "</h1>",
|
|
"</td></tr>",
|
|
'<tr><td style="padding:24px;">',
|
|
'<p style="margin:0 0 12px 0;font-size:15px;line-height:1.6;">Hallo' + (user && user.email ? " " + escapeHtml(user.email) : "") + ",</p>",
|
|
'<p style="margin:0 0 16px 0;font-size:15px;line-height:1.65;color:#233b57;">' + safeTextHtml + "</p>",
|
|
(code ? '<div style="margin:0 0 16px 0;padding:14px 16px;border:1px dashed #9fb1c8;border-radius:10px;background:#f8fbff;"><span style="font-size:12px;color:#5d6f85;display:block;margin-bottom:6px;">Dein Code</span><strong style="font-size:26px;letter-spacing:0.08em;color:#10253f;">' + safeCode + "</strong></div>" : ""),
|
|
(actionLink ? '<p style="margin:0 0 18px 0;"><a href="' + safeActionLink + '" style="display:inline-block;background:#0b4fa8;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:8px;font-weight:700;">' + safeActionLabel + "</a></p>" : ""),
|
|
(actionLink ? '<p style="margin:0;font-size:12px;line-height:1.5;color:#5d6f85;word-break:break-all;">Falls der Button nicht funktioniert, oeffne diesen Link im Browser:<br><a href="' + safeActionLink + '" style="color:#0b4fa8;">' + safeActionLink + "</a></p>" : ""),
|
|
"</td></tr>",
|
|
'<tr><td style="padding:16px 24px;background:#f8fafc;border-top:1px solid #edf1f6;font-size:12px;line-height:1.6;color:#5d6f85;">',
|
|
"Diese Nachricht wurde automatisch von " + safeStationName + " gesendet.",
|
|
"</td></tr>",
|
|
"</table>",
|
|
"</td></tr>",
|
|
"</table>",
|
|
"</body>",
|
|
"</html>"
|
|
].join("");
|
|
|
|
return { subject, text, html };
|
|
}
|
|
|
|
function toAbsoluteUrl(baseUrl, maybeRelative) {
|
|
const value = String(maybeRelative || "").trim();
|
|
if (!value) {
|
|
return "";
|
|
}
|
|
if (/^https?:\/\//i.test(value)) {
|
|
return value;
|
|
}
|
|
const base = String(baseUrl || "").trim();
|
|
if (!base) {
|
|
return value;
|
|
}
|
|
const baseTrimmed = base.replace(/\/$/, "");
|
|
return `${baseTrimmed}/${value.replace(/^\/+/, "")}`;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/\"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function domainForEmail(email) {
|
|
const at = email.indexOf("@");
|
|
return at === -1 ? "" : email.slice(at + 1).toLowerCase();
|
|
}
|
|
|
|
function isPrimaryDomainEmail(email) {
|
|
return domainForEmail(email) === config.primaryEmailDomain;
|
|
}
|
|
|
|
function stationUsageDurationMs() {
|
|
return Math.max(1, Number(config.stationMaxUsageSec || 3600)) * 1000;
|
|
}
|
|
|
|
function computeLeaseEndIso(startedAtIso) {
|
|
const startedMs = new Date(startedAtIso || Date.now()).getTime();
|
|
return new Date(startedMs + stationUsageDurationMs()).toISOString();
|
|
}
|
|
|
|
function parseIsoMs(value) {
|
|
const ms = Date.parse(String(value || ""));
|
|
return Number.isFinite(ms) ? ms : null;
|
|
}
|
|
|
|
function normalizeStationReservations(value, options = {}) {
|
|
const slotMs = stationUsageDurationMs();
|
|
const nowMs = Number.isFinite(Number(options.nowMs)) ? Number(options.nowMs) : Date.now();
|
|
const baseStart = Number.isFinite(Number(options.slotStartBaseMs)) ? Math.floor(Number(options.slotStartBaseMs)) : null;
|
|
let cursorMs = baseStart;
|
|
const source = Array.isArray(value) ? value : [];
|
|
const out = [];
|
|
const seenUserIds = new Set();
|
|
for (const entry of source) {
|
|
if (!entry || typeof entry !== "object") {
|
|
continue;
|
|
}
|
|
const userId = String(entry.userId || "").trim();
|
|
const email = String(entry.email || "").trim();
|
|
if (!userId || !email || seenUserIds.has(userId)) {
|
|
continue;
|
|
}
|
|
seenUserIds.add(userId);
|
|
|
|
let fromMs = parseIsoMs(entry.from);
|
|
let toMs = parseIsoMs(entry.to);
|
|
if (!(Number.isFinite(fromMs) && Number.isFinite(toMs) && toMs > fromMs)) {
|
|
if (Number.isFinite(cursorMs)) {
|
|
fromMs = cursorMs;
|
|
toMs = fromMs + slotMs;
|
|
} else {
|
|
fromMs = nowMs;
|
|
toMs = fromMs + slotMs;
|
|
}
|
|
}
|
|
cursorMs = toMs;
|
|
|
|
out.push({
|
|
userId,
|
|
email,
|
|
from: new Date(fromMs).toISOString(),
|
|
to: new Date(toMs).toISOString(),
|
|
createdAt: entry.createdAt ? String(entry.createdAt) : new Date(nowMs).toISOString()
|
|
});
|
|
}
|
|
out.sort((a, b) => {
|
|
const aFrom = parseIsoMs(a.from) || 0;
|
|
const bFrom = parseIsoMs(b.from) || 0;
|
|
return aFrom - bFrom;
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function removeReservationByUserId(reservations, userId) {
|
|
const targetId = String(userId || "");
|
|
if (!targetId) {
|
|
return { changed: false, reservations: normalizeStationReservations(reservations) };
|
|
}
|
|
const normalized = normalizeStationReservations(reservations);
|
|
const next = normalized.filter((entry) => String(entry.userId) !== targetId);
|
|
return {
|
|
changed: next.length !== normalized.length,
|
|
reservations: next
|
|
};
|
|
}
|
|
|
|
function getFirstFutureReservationStartMs(reservations, nowMs) {
|
|
let minStart = null;
|
|
for (const entry of reservations) {
|
|
const fromMs = parseIsoMs(entry.from);
|
|
if (!Number.isFinite(fromMs) || fromMs <= nowMs) {
|
|
continue;
|
|
}
|
|
if (minStart === null || fromMs < minStart) {
|
|
minStart = fromMs;
|
|
}
|
|
}
|
|
return minStart;
|
|
}
|
|
|
|
function findActiveReservationEntry(reservations, nowMs = Date.now()) {
|
|
for (const entry of reservations) {
|
|
const fromMs = parseIsoMs(entry.from);
|
|
const toMs = parseIsoMs(entry.to);
|
|
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) {
|
|
continue;
|
|
}
|
|
if (fromMs <= nowMs && nowMs < toMs) {
|
|
return entry;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function pruneExpiredReservations(reservations, nowMs = Date.now()) {
|
|
const next = [];
|
|
for (const entry of reservations) {
|
|
const toMs = parseIsoMs(entry.to);
|
|
if (!Number.isFinite(toMs) || toMs <= nowMs) {
|
|
continue;
|
|
}
|
|
next.push(entry);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function getReservationBaseStartMs(nowMs = Date.now()) {
|
|
const currentEndsAtMs = parseIsoMs(runtime.station && runtime.station.endsAt);
|
|
if (runtime.station && runtime.station.isInUse && Number.isFinite(currentEndsAtMs) && currentEndsAtMs > nowMs) {
|
|
return currentEndsAtMs;
|
|
}
|
|
return nowMs + stationUsageDurationMs();
|
|
}
|
|
|
|
function getNormalizedStationReservations(nowMs = Date.now()) {
|
|
let normalized = normalizeStationReservations(runtime.station && runtime.station.reservations, {
|
|
nowMs,
|
|
slotStartBaseMs: getReservationBaseStartMs(nowMs)
|
|
});
|
|
normalized = pruneExpiredReservations(normalized, nowMs);
|
|
if (runtime.station && runtime.station.isInUse && runtime.station.activeByUserId && runtime.station.activeByEmail) {
|
|
const activeEntry = findActiveReservationEntry(normalized, nowMs);
|
|
if (!activeEntry || String(activeEntry.userId || "") !== String(runtime.station.activeByUserId || "")) {
|
|
const withoutOwner = removeReservationByUserId(normalized, runtime.station.activeByUserId).reservations;
|
|
const fromMs = parseIsoMs(runtime.station.startedAt) || nowMs;
|
|
let toMs = parseIsoMs(runtime.station.endsAt) || (fromMs + stationUsageDurationMs());
|
|
if (toMs <= fromMs) {
|
|
toMs = fromMs + 1000;
|
|
}
|
|
normalized = normalizeStationReservations([
|
|
{
|
|
userId: String(runtime.station.activeByUserId || ""),
|
|
email: String(runtime.station.activeByEmail || "unknown"),
|
|
from: new Date(fromMs).toISOString(),
|
|
to: new Date(toMs).toISOString(),
|
|
createdAt: runtime.station.startedAt || new Date(nowMs).toISOString()
|
|
},
|
|
...withoutOwner
|
|
], {
|
|
nowMs,
|
|
slotStartBaseMs: getReservationBaseStartMs(nowMs)
|
|
});
|
|
}
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function getReservationAccessLock(nowMs = Date.now()) {
|
|
const reservations = getNormalizedStationReservations(nowMs);
|
|
const activeEntry = findActiveReservationEntry(reservations, nowMs);
|
|
return {
|
|
reservations,
|
|
activeEntry,
|
|
locked: Boolean(activeEntry)
|
|
};
|
|
}
|
|
|
|
function hasStationReservationAccess(user, lockInfo) {
|
|
if (!lockInfo || !lockInfo.activeEntry) {
|
|
return true;
|
|
}
|
|
if (user && user.role === "admin") {
|
|
return true;
|
|
}
|
|
return String(lockInfo.activeEntry.userId || "") === String(user && user.id ? user.id : "");
|
|
}
|
|
|
|
function buildCurrentSlotForUser(user, reservations, nowMs = Date.now()) {
|
|
const cleaned = removeReservationByUserId(reservations, user.id).reservations;
|
|
const nextStartMs = getFirstFutureReservationStartMs(cleaned, nowMs);
|
|
let toMs = nowMs + stationUsageDurationMs();
|
|
if (Number.isFinite(nextStartMs)) {
|
|
toMs = Math.min(toMs, nextStartMs);
|
|
}
|
|
if (toMs <= nowMs) {
|
|
toMs = nowMs + 1000;
|
|
}
|
|
const current = {
|
|
userId: String(user.id || ""),
|
|
email: String(user.email || "unknown"),
|
|
from: new Date(nowMs).toISOString(),
|
|
to: new Date(toMs).toISOString(),
|
|
createdAt: new Date(nowMs).toISOString()
|
|
};
|
|
return normalizeStationReservations([current, ...cleaned], {
|
|
nowMs,
|
|
slotStartBaseMs: getReservationBaseStartMs(nowMs)
|
|
});
|
|
}
|
|
|
|
async function persistStationReservationsIfChanged(nextReservations, meta = {}) {
|
|
const currentRaw = Array.isArray(runtime.station && runtime.station.reservations)
|
|
? runtime.station.reservations
|
|
: [];
|
|
const changed = JSON.stringify(currentRaw) !== JSON.stringify(nextReservations);
|
|
if (!changed) {
|
|
return false;
|
|
}
|
|
runtime.station = {
|
|
...runtime.station,
|
|
reservations: nextReservations,
|
|
updatedAt: new Date().toISOString(),
|
|
lastAction: meta.lastAction || runtime.station.lastAction
|
|
};
|
|
await writeJson(files.station, runtime.station);
|
|
return true;
|
|
}
|
|
|
|
async function autoActivateReservationSlotIfNeeded() {
|
|
return withLock(async () => autoActivateReservationSlotIfNeededUnlocked({}));
|
|
}
|
|
|
|
async function autoActivateReservationSlotIfNeededUnlocked(options = {}) {
|
|
if (runtime.currentActivationJobId || (runtime.station && runtime.station.isInUse)) {
|
|
return false;
|
|
}
|
|
const excludeUserId = options && options.excludeUserId ? String(options.excludeUserId) : "";
|
|
const nowMs = Date.now();
|
|
const reservations = getNormalizedStationReservations(nowMs);
|
|
await persistStationReservationsIfChanged(reservations, { lastAction: "reserve-sync" });
|
|
const activeEntry = findActiveReservationEntry(reservations, nowMs);
|
|
if (!activeEntry) {
|
|
if (runtime.station.manualReleaseReservationUserId || runtime.station.manualReleaseReservationUntil) {
|
|
runtime.station.manualReleaseReservationUserId = null;
|
|
runtime.station.manualReleaseReservationUntil = null;
|
|
runtime.station.updatedAt = new Date().toISOString();
|
|
await writeJson(files.station, runtime.station);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const holdUserId = String(runtime.station.manualReleaseReservationUserId || "");
|
|
const holdUntilMs = parseIsoMs(runtime.station.manualReleaseReservationUntil);
|
|
if (
|
|
holdUserId
|
|
&& Number.isFinite(holdUntilMs)
|
|
&& holdUntilMs > nowMs
|
|
&& String(activeEntry.userId || "") === holdUserId
|
|
) {
|
|
return false;
|
|
}
|
|
if (holdUserId || runtime.station.manualReleaseReservationUntil) {
|
|
runtime.station.manualReleaseReservationUserId = null;
|
|
runtime.station.manualReleaseReservationUntil = null;
|
|
runtime.station.updatedAt = new Date().toISOString();
|
|
await writeJson(files.station, runtime.station);
|
|
}
|
|
|
|
if (excludeUserId && String(activeEntry.userId || "") === excludeUserId) {
|
|
return false;
|
|
}
|
|
const user = runtime.users.find((entry) => String(entry.id || "") === String(activeEntry.userId || ""));
|
|
if (!user) {
|
|
return false;
|
|
}
|
|
try {
|
|
await executeCapability("station.activate", "activate", { userEmail: user.email }, { user, skipTxSafety: true });
|
|
} catch (error) {
|
|
await appendAudit("station.activate.auto_slot.failed", user, {
|
|
error: String(error && error.message ? error.message : error)
|
|
});
|
|
return false;
|
|
}
|
|
|
|
runtime.station = {
|
|
...runtime.station,
|
|
isInUse: true,
|
|
activeByUserId: user.id,
|
|
activeByEmail: user.email,
|
|
startedAt: activeEntry.from,
|
|
endsAt: activeEntry.to,
|
|
reservations,
|
|
manualReleaseReservationUserId: null,
|
|
manualReleaseReservationUntil: null,
|
|
updatedAt: new Date().toISOString(),
|
|
lastAction: "activate-auto-slot"
|
|
};
|
|
await writeJson(files.station, runtime.station);
|
|
await syncStationAccessPolicyOwner(user, user.email);
|
|
await appendAudit("station.activate.auto_slot", user, {
|
|
from: activeEntry.from,
|
|
to: activeEntry.to
|
|
});
|
|
broadcastEvent("station.status.changed", buildStationStatusView());
|
|
return true;
|
|
}
|
|
|
|
async function reconcileStationLeaseOnStartup() {
|
|
if (!runtime.station) {
|
|
return;
|
|
}
|
|
let changed = false;
|
|
const normalizedReservations = getNormalizedStationReservations(Date.now());
|
|
const reservationsChanged = !Array.isArray(runtime.station.reservations)
|
|
|| JSON.stringify(normalizedReservations) !== JSON.stringify(runtime.station.reservations);
|
|
if (reservationsChanged) {
|
|
runtime.station.reservations = normalizedReservations;
|
|
changed = true;
|
|
}
|
|
if (!("endsAt" in runtime.station)) {
|
|
runtime.station.endsAt = null;
|
|
changed = true;
|
|
}
|
|
if (!Object.prototype.hasOwnProperty.call(runtime.station, "manualReleaseReservationUserId")) {
|
|
runtime.station.manualReleaseReservationUserId = null;
|
|
changed = true;
|
|
}
|
|
if (!Object.prototype.hasOwnProperty.call(runtime.station, "manualReleaseReservationUntil")) {
|
|
runtime.station.manualReleaseReservationUntil = null;
|
|
changed = true;
|
|
}
|
|
if (runtime.station.isInUse && runtime.station.startedAt && !runtime.station.endsAt) {
|
|
runtime.station.endsAt = computeLeaseEndIso(runtime.station.startedAt);
|
|
changed = true;
|
|
}
|
|
if (!runtime.station.isInUse && runtime.station.endsAt) {
|
|
runtime.station.endsAt = null;
|
|
changed = true;
|
|
}
|
|
if (changed) {
|
|
runtime.station.updatedAt = new Date().toISOString();
|
|
await writeJson(files.station, runtime.station);
|
|
}
|
|
await enforceStationLeaseTimeout();
|
|
}
|
|
|
|
async function enforceStationLeaseTimeout() {
|
|
if (!runtime.station) {
|
|
return;
|
|
}
|
|
if (!runtime.station.isInUse || runtime.currentActivationJobId) {
|
|
await autoActivateReservationSlotIfNeeded();
|
|
return;
|
|
}
|
|
if (!runtime.station.endsAt && runtime.station.startedAt) {
|
|
runtime.station.endsAt = computeLeaseEndIso(runtime.station.startedAt);
|
|
runtime.station.updatedAt = new Date().toISOString();
|
|
await writeJson(files.station, runtime.station);
|
|
broadcastEvent("station.status.changed", buildStationStatusView());
|
|
}
|
|
if (!runtime.station.endsAt) {
|
|
return;
|
|
}
|
|
if (Date.now() < new Date(runtime.station.endsAt).getTime()) {
|
|
return;
|
|
}
|
|
await releaseStationByTimeout();
|
|
}
|
|
|
|
async function releaseStationByTimeout() {
|
|
return withLock(async () => {
|
|
if (!runtime.station.isInUse || runtime.currentActivationJobId) {
|
|
return;
|
|
}
|
|
const activeEmail = runtime.station.activeByEmail || "unknown";
|
|
const activeUser = runtime.users.find((entry) => entry.id === runtime.station.activeByUserId)
|
|
|| { id: null, email: activeEmail, role: "operator" };
|
|
let deactivateError = null;
|
|
try {
|
|
await safeShutdownStationSession(activeUser, "lease-timeout");
|
|
} catch (error) {
|
|
deactivateError = String(error && error.message ? error.message : error);
|
|
await appendAudit("station.deactivate.timeout.failed", activeUser, { deactivateError });
|
|
return;
|
|
}
|
|
|
|
const keptReservations = getNormalizedStationReservations(Date.now());
|
|
runtime.station = {
|
|
...runtime.station,
|
|
isInUse: false,
|
|
activeByUserId: null,
|
|
activeByEmail: null,
|
|
startedAt: null,
|
|
endsAt: null,
|
|
reservations: keptReservations,
|
|
updatedAt: new Date().toISOString(),
|
|
lastAction: deactivateError ? "deactivate-timeout-forced" : "deactivate-timeout"
|
|
};
|
|
await writeJson(files.station, runtime.station);
|
|
await appendAudit("station.deactivate.timeout", activeUser, {
|
|
maxUsageSec: config.stationMaxUsageSec,
|
|
deactivateError
|
|
});
|
|
broadcastEvent("station.deactivate.timeout", {
|
|
email: activeEmail,
|
|
deactivateError,
|
|
maxUsageSec: config.stationMaxUsageSec
|
|
});
|
|
const autoActivated = await autoActivateReservationSlotIfNeededUnlocked({
|
|
excludeUserId: activeUser && activeUser.id ? String(activeUser.id) : ""
|
|
});
|
|
if (!autoActivated) {
|
|
broadcastEvent("station.status.changed", buildStationStatusView());
|
|
}
|
|
});
|
|
}
|
|
|
|
async function handleActivationStart(res, user) {
|
|
return withLock(async () => {
|
|
const nowMs = Date.now();
|
|
const lockInfo = getReservationAccessLock(nowMs);
|
|
if (!hasStationReservationAccess(user, lockInfo)) {
|
|
return sendError(res, 403, "station.slot_owner_only", "Nur der reservierte Benutzer darf diesen Slot aktivieren/deaktivieren");
|
|
}
|
|
if (runtime.swrRun && runtime.swrRun.running) {
|
|
return sendError(res, 409, "swr.running", "Stationaktivierung ist waehrend laufendem SWR-Check gesperrt");
|
|
}
|
|
if (runtime.currentActivationJobId) {
|
|
const current = runtime.jobs.get(runtime.currentActivationJobId);
|
|
if (current && current.startedBy && String(current.startedBy) !== String(user.email || "")) {
|
|
return sendError(res, 409, "station.in_use", "Stationaktivierung laeuft bereits fuer einen anderen Benutzer");
|
|
}
|
|
return sendJson(res, 202, { ok: true, pending: true, job: current });
|
|
}
|
|
|
|
if (!runtime.station.stationOnline) {
|
|
return sendError(res, 409, "station.offline", "Station ist offline");
|
|
}
|
|
if (runtime.station.isInUse) {
|
|
return sendError(res, 409, "station.in_use", "Station wird bereits verwendet");
|
|
}
|
|
|
|
const activeReservation = lockInfo.activeEntry;
|
|
let reservations = lockInfo.reservations;
|
|
if (activeReservation) {
|
|
reservations = normalizeStationReservations(reservations.map((entry) => {
|
|
if (String(entry.userId) !== String(user.id || "")) {
|
|
return entry;
|
|
}
|
|
return {
|
|
...entry,
|
|
email: String(user.email || entry.email || "unknown")
|
|
};
|
|
}), { nowMs, slotStartBaseMs: getReservationBaseStartMs(nowMs) });
|
|
} else {
|
|
reservations = buildCurrentSlotForUser(user, reservations, nowMs);
|
|
}
|
|
await persistStationReservationsIfChanged(reservations, { lastAction: "reserve-current" });
|
|
|
|
const selectedSlot = findActiveReservationEntry(reservations, nowMs);
|
|
if (!selectedSlot || String(selectedSlot.userId || "") !== String(user.id || "")) {
|
|
return sendError(res, 409, "station.slot_unavailable", "Aktueller Slot ist nicht verfuegbar");
|
|
}
|
|
|
|
let txState = await readTransmitState(user);
|
|
if (txState.txActive && shouldAutoDisableTxBeforeActivation()) {
|
|
try {
|
|
await executeCapability("tx.control", "disableTx", { source: "activation-precheck" }, { user, skipTxSafety: true });
|
|
await appendAudit("tx.disable", user, { source: "activation-precheck", auto: true });
|
|
txState = await readTransmitState(user);
|
|
} catch (error) {
|
|
await appendAudit("tx.disable.failed", user, {
|
|
source: "activation-precheck",
|
|
error: String(error && error.message ? error.message : error)
|
|
});
|
|
}
|
|
}
|
|
if (txState.txActive) {
|
|
return sendError(res, 409, "tx.switch_locked", "Umschalten ist waehrend aktivem Senden gesperrt", {
|
|
...txState,
|
|
hint: "TX muss zuerst deaktiviert werden"
|
|
});
|
|
}
|
|
|
|
const job = {
|
|
id: `job_${crypto.randomUUID()}`,
|
|
type: "station.activate",
|
|
status: "running",
|
|
phase: "swr-check",
|
|
percent: 0,
|
|
etaSec: null,
|
|
startedAt: new Date().toISOString(),
|
|
startedBy: user.email,
|
|
startedByUserId: user.id,
|
|
slotFrom: selectedSlot.from,
|
|
slotTo: selectedSlot.to,
|
|
finishedAt: null,
|
|
error: null
|
|
};
|
|
runtime.currentActivationJobId = job.id;
|
|
runtime.jobs.set(job.id, job);
|
|
broadcastEvent("station.activation.started", { job });
|
|
appendAudit("station.activate.start", user, { jobId: job.id }).catch(() => {});
|
|
|
|
runActivationJob(job, user).catch((error) => {
|
|
console.error("Activation job failed", error);
|
|
});
|
|
|
|
return sendJson(res, 202, { ok: true, pending: true, job });
|
|
});
|
|
}
|
|
|
|
async function runActivationJob(job, user) {
|
|
let progressTimer = null;
|
|
try {
|
|
const expectedDurationMs = Number(getPluginSetting("rms.vswr.nanovna", "expectedDurationMs", process.env.SWR_CHECK_DURATION_MS || 54000));
|
|
const startedMs = Date.now();
|
|
progressTimer = setInterval(() => {
|
|
const elapsed = Date.now() - startedMs;
|
|
const pct = Math.max(0, Math.min(99, Math.floor((elapsed / Math.max(1, expectedDurationMs)) * 100)));
|
|
job.percent = pct;
|
|
job.etaSec = Math.max(0, Math.ceil((expectedDurationMs - elapsed) / 1000));
|
|
broadcastEvent("station.activation.progress", {
|
|
jobId: job.id,
|
|
phase: job.phase,
|
|
percent: job.percent,
|
|
etaSec: job.etaSec
|
|
});
|
|
}, 1000);
|
|
|
|
let swrRunError = null;
|
|
let swrReport = null;
|
|
const swrToken = await startGlobalSwrRun(user, {
|
|
source: "station-activation",
|
|
phase: "swr-check",
|
|
expectedDurationMs
|
|
});
|
|
try {
|
|
await executeCapability("vswr.run", "runCheck", {}, { user });
|
|
swrReport = await buildSwrReportView(user);
|
|
broadcastEvent("swr.report.changed", {
|
|
source: "station-activation",
|
|
generatedAt: swrReport && swrReport.generatedAt ? swrReport.generatedAt : null,
|
|
overallStatus: swrReport && swrReport.overallStatus ? swrReport.overallStatus : null
|
|
});
|
|
} catch (error) {
|
|
swrRunError = String(error && error.message ? error.message : error);
|
|
await appendAudit("station.activate.swr_failed_continue", user, {
|
|
jobId: job.id,
|
|
error: swrRunError
|
|
});
|
|
} finally {
|
|
await finishGlobalSwrRun(swrToken, {
|
|
status: swrRunError ? "failed" : "succeeded",
|
|
error: swrRunError
|
|
});
|
|
}
|
|
|
|
job.phase = "switch-to-transceiver";
|
|
await executeCapability("station.activate", "activate", { userEmail: user.email }, { user, skipTxSafety: true });
|
|
|
|
await withLock(async () => {
|
|
const nowMs = Date.now();
|
|
const startedAt = job.slotFrom || new Date(nowMs).toISOString();
|
|
const endsAt = job.slotTo || computeLeaseEndIso(startedAt);
|
|
const reservationsAfterActivate = normalizeStationReservations(runtime.station.reservations, {
|
|
nowMs,
|
|
slotStartBaseMs: getReservationBaseStartMs(nowMs)
|
|
});
|
|
runtime.station = {
|
|
...runtime.station,
|
|
isInUse: true,
|
|
activeByUserId: user.id,
|
|
activeByEmail: user.email,
|
|
startedAt,
|
|
endsAt,
|
|
reservations: reservationsAfterActivate,
|
|
manualReleaseReservationUserId: null,
|
|
manualReleaseReservationUntil: null,
|
|
updatedAt: new Date().toISOString(),
|
|
lastAction: "activate"
|
|
};
|
|
await writeJson(files.station, runtime.station);
|
|
runtime.currentActivationJobId = null;
|
|
});
|
|
|
|
await syncStationAccessPolicyOwner(user, user.email);
|
|
|
|
job.status = "succeeded";
|
|
job.percent = 100;
|
|
job.etaSec = 0;
|
|
job.finishedAt = new Date().toISOString();
|
|
broadcastEvent("station.activation.completed", { jobId: job.id, swrReport });
|
|
broadcastEvent("station.status.changed", buildStationStatusView());
|
|
await appendAudit("station.activate.done", user, {
|
|
jobId: job.id,
|
|
swrFailed: Boolean(swrRunError),
|
|
swrError: swrRunError
|
|
});
|
|
} catch (error) {
|
|
try {
|
|
await withLock(async () => {
|
|
runtime.currentActivationJobId = null;
|
|
runtime.station = {
|
|
...runtime.station,
|
|
updatedAt: new Date().toISOString(),
|
|
lastAction: "activate-failed"
|
|
};
|
|
await writeJson(files.station, runtime.station);
|
|
});
|
|
} catch {
|
|
runtime.currentActivationJobId = null;
|
|
}
|
|
job.status = "failed";
|
|
job.finishedAt = new Date().toISOString();
|
|
job.error = String(error && error.message ? error.message : error);
|
|
broadcastEvent("station.activation.failed", { jobId: job.id, error: job.error });
|
|
await appendAudit("station.activate.failed", user, { jobId: job.id, error: job.error });
|
|
} finally {
|
|
if (progressTimer) {
|
|
clearInterval(progressTimer);
|
|
}
|
|
if (runtime.currentActivationJobId === job.id) {
|
|
runtime.currentActivationJobId = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runSwrCheckAndBuildReport(user) {
|
|
const swrToken = await startGlobalSwrRun(user, {
|
|
source: "manual",
|
|
phase: "swr-check",
|
|
expectedDurationMs: getExpectedSWRDurationMs()
|
|
});
|
|
let runError = null;
|
|
try {
|
|
const result = await executeCapability("vswr.run", "runCheck", {}, { user });
|
|
const report = await buildSwrReportView(user);
|
|
return { result, report };
|
|
} catch (error) {
|
|
runError = error;
|
|
throw error;
|
|
} finally {
|
|
await finishGlobalSwrRun(swrToken, {
|
|
status: runError ? "failed" : "succeeded",
|
|
error: runError ? String(runError && runError.message ? runError.message : runError) : null
|
|
});
|
|
}
|
|
}
|
|
|
|
async function handleStationRelease(res, user) {
|
|
return withLock(async () => {
|
|
const lockInfo = getReservationAccessLock(Date.now());
|
|
if (!hasStationReservationAccess(user, lockInfo)) {
|
|
return sendError(res, 403, "station.slot_owner_only", "Nur der reservierte Benutzer darf diesen Slot aktivieren/deaktivieren");
|
|
}
|
|
if (runtime.currentActivationJobId) {
|
|
return sendError(res, 423, "station.activation_running", "Waehrend SWR-Check nicht moeglich");
|
|
}
|
|
if (!runtime.station.isInUse) {
|
|
return sendJson(res, 200, { ok: true, status: buildStationStatusView() });
|
|
}
|
|
if (runtime.station.activeByUserId !== user.id && user.role !== "admin") {
|
|
return sendError(res, 403, "station.not_owner", "Nur aktiver Benutzer oder Admin darf freigeben");
|
|
}
|
|
|
|
try {
|
|
await safeShutdownStationSession(user, "manual-release");
|
|
} catch (error) {
|
|
if (error && error.code === "TX_DISABLE_FAILED") {
|
|
return sendError(res, 409, "tx.disable_failed", String(error.message || error), error.details || null);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
runtime.station = {
|
|
...runtime.station,
|
|
isInUse: false,
|
|
activeByUserId: null,
|
|
activeByEmail: null,
|
|
startedAt: null,
|
|
endsAt: null,
|
|
reservations: lockInfo.reservations,
|
|
manualReleaseReservationUserId: lockInfo.activeEntry && String(lockInfo.activeEntry.userId || "") === String(user && user.id ? user.id : "")
|
|
? String(user.id || "")
|
|
: null,
|
|
manualReleaseReservationUntil: lockInfo.activeEntry && String(lockInfo.activeEntry.userId || "") === String(user && user.id ? user.id : "")
|
|
? String(lockInfo.activeEntry.to || "")
|
|
: null,
|
|
updatedAt: new Date().toISOString(),
|
|
lastAction: "deactivate"
|
|
};
|
|
await writeJson(files.station, runtime.station);
|
|
await appendAudit("station.deactivate", user, null);
|
|
broadcastEvent("station.status.changed", buildStationStatusView());
|
|
return sendJson(res, 200, { ok: true, status: buildStationStatusView() });
|
|
});
|
|
}
|
|
|
|
async function handleReserveNextSlot(res, user) {
|
|
return withLock(async () => {
|
|
const nowMs = Date.now();
|
|
const activation = getCurrentActivationView();
|
|
const stationOccupied = Boolean(runtime.station.isInUse || activation.running);
|
|
if (!stationOccupied) {
|
|
return sendError(res, 409, "station.not_occupied", "Reservierung nur moeglich solange die Station belegt ist");
|
|
}
|
|
const reservations = getNormalizedStationReservations(nowMs);
|
|
const existing = reservations.find((entry) => String(entry.userId) === String(user.id || ""));
|
|
if (existing) {
|
|
return sendJson(res, 200, { ok: true, alreadyReserved: true, status: buildStationStatusView() });
|
|
}
|
|
|
|
const lastToMs = reservations.reduce((max, entry) => {
|
|
const toMs = parseIsoMs(entry.to);
|
|
return Number.isFinite(toMs) ? Math.max(max, toMs) : max;
|
|
}, 0);
|
|
const startMs = lastToMs > 0 ? lastToMs : getReservationBaseStartMs(nowMs);
|
|
const endMs = startMs + stationUsageDurationMs();
|
|
|
|
const nextReservations = [
|
|
...reservations,
|
|
{
|
|
userId: String(user.id || ""),
|
|
email: String(user.email || "unknown"),
|
|
from: new Date(startMs).toISOString(),
|
|
to: new Date(endMs).toISOString(),
|
|
createdAt: new Date(nowMs).toISOString()
|
|
}
|
|
];
|
|
runtime.station = {
|
|
...runtime.station,
|
|
reservations: nextReservations,
|
|
updatedAt: new Date().toISOString(),
|
|
lastAction: "reserve-next"
|
|
};
|
|
await writeJson(files.station, runtime.station);
|
|
await appendAudit("station.reserve.next", user, { queueLength: nextReservations.length });
|
|
broadcastEvent("station.status.changed", buildStationStatusView());
|
|
return sendJson(res, 200, { ok: true, status: buildStationStatusView() });
|
|
});
|
|
}
|
|
|
|
async function handleDeleteOwnReservation(res, user) {
|
|
return handleDeleteReservationByUserId(res, user, user.id, false);
|
|
}
|
|
|
|
async function handleDeleteReservationByUserId(res, actorUser, targetUserId, adminMode = false) {
|
|
return withLock(async () => {
|
|
const nowMs = Date.now();
|
|
const reservations = getNormalizedStationReservations(nowMs);
|
|
const targetId = String(targetUserId || "");
|
|
const targetEntry = reservations.find((entry) => String(entry.userId || "") === targetId);
|
|
if (!targetEntry) {
|
|
return sendError(
|
|
res,
|
|
404,
|
|
"station.reservation.not_found",
|
|
adminMode ? "Reservierung nicht gefunden" : "Keine eigene Reservierung gefunden"
|
|
);
|
|
}
|
|
const removed = removeReservationByUserId(reservations, targetId);
|
|
if (!removed.changed) {
|
|
return sendError(
|
|
res,
|
|
404,
|
|
"station.reservation.not_found",
|
|
adminMode ? "Reservierung nicht gefunden" : "Keine eigene Reservierung gefunden"
|
|
);
|
|
}
|
|
|
|
const fromMs = parseIsoMs(targetEntry.from);
|
|
const toMs = parseIsoMs(targetEntry.to);
|
|
const deletingActiveSlot = Number.isFinite(fromMs)
|
|
&& Number.isFinite(toMs)
|
|
&& fromMs <= nowMs
|
|
&& nowMs < toMs;
|
|
const targetIsCurrentOwner = runtime.station.isInUse && String(runtime.station.activeByUserId || "") === targetId;
|
|
|
|
if (deletingActiveSlot && targetIsCurrentOwner) {
|
|
try {
|
|
await safeShutdownStationSession(actorUser, "reservation-delete-current");
|
|
} catch (error) {
|
|
if (error && error.code === "TX_DISABLE_FAILED") {
|
|
return sendError(res, 409, "tx.disable_failed", String(error.message || error), error.details || null);
|
|
}
|
|
throw error;
|
|
}
|
|
runtime.station = {
|
|
...runtime.station,
|
|
isInUse: false,
|
|
activeByUserId: null,
|
|
activeByEmail: null,
|
|
startedAt: null,
|
|
endsAt: null,
|
|
reservations: removed.reservations,
|
|
manualReleaseReservationUserId: String(runtime.station.manualReleaseReservationUserId || "") === targetId
|
|
? null
|
|
: (runtime.station.manualReleaseReservationUserId || null),
|
|
manualReleaseReservationUntil: String(runtime.station.manualReleaseReservationUserId || "") === targetId
|
|
? null
|
|
: (runtime.station.manualReleaseReservationUntil || null),
|
|
updatedAt: new Date().toISOString(),
|
|
lastAction: adminMode ? "reserve-remove-current-admin" : "reserve-remove-current"
|
|
};
|
|
await writeJson(files.station, runtime.station);
|
|
await appendAudit("station.reserve.remove", actorUser, {
|
|
queueLength: removed.reservations.length,
|
|
currentSlot: true,
|
|
targetUserId: targetId,
|
|
mode: adminMode ? "admin" : "self"
|
|
});
|
|
const autoActivated = await autoActivateReservationSlotIfNeededUnlocked({ excludeUserId: targetId });
|
|
if (!autoActivated) {
|
|
broadcastEvent("station.status.changed", buildStationStatusView());
|
|
}
|
|
return sendJson(res, 200, { ok: true, status: buildStationStatusView() });
|
|
}
|
|
|
|
runtime.station = {
|
|
...runtime.station,
|
|
reservations: removed.reservations,
|
|
manualReleaseReservationUserId: String(runtime.station.manualReleaseReservationUserId || "") === targetId
|
|
? null
|
|
: (runtime.station.manualReleaseReservationUserId || null),
|
|
manualReleaseReservationUntil: String(runtime.station.manualReleaseReservationUserId || "") === targetId
|
|
? null
|
|
: (runtime.station.manualReleaseReservationUntil || null),
|
|
updatedAt: new Date().toISOString(),
|
|
lastAction: adminMode ? "reserve-remove-admin" : "reserve-remove"
|
|
};
|
|
await writeJson(files.station, runtime.station);
|
|
await appendAudit("station.reserve.remove", actorUser, {
|
|
queueLength: removed.reservations.length,
|
|
targetUserId: targetId,
|
|
mode: adminMode ? "admin" : "self"
|
|
});
|
|
broadcastEvent("station.status.changed", buildStationStatusView());
|
|
return sendJson(res, 200, { ok: true, status: buildStationStatusView() });
|
|
});
|
|
}
|
|
|
|
async function safeShutdownStationSession(user, reason) {
|
|
if (runtime.pttActive) {
|
|
try {
|
|
await executeCapability("microham.ptt", "pttUp", {}, { user, skipTxSafety: true });
|
|
} catch {
|
|
// best effort
|
|
}
|
|
try {
|
|
await executeCapability("rfroute.set", "setRoute", { route: "rx" }, { user, skipTxSafety: true, allowPttOverride: true });
|
|
} catch {
|
|
// best effort
|
|
}
|
|
runtime.pttActive = false;
|
|
}
|
|
const txStateBefore = await readTransmitState(user);
|
|
if (txStateBefore.txActive) {
|
|
try {
|
|
await executeCapability("tx.control", "disableTx", { reason }, { user, skipTxSafety: true });
|
|
await appendAudit("tx.disable", user, { reason, source: "session-shutdown" });
|
|
} catch (error) {
|
|
const wrapped = new Error("TX konnte vor Session-Ende nicht deaktiviert werden");
|
|
wrapped.code = "TX_DISABLE_FAILED";
|
|
wrapped.details = {
|
|
reason,
|
|
error: String(error && error.message ? error.message : error)
|
|
};
|
|
throw wrapped;
|
|
}
|
|
}
|
|
|
|
await executeCapability("tx.audio", "audioDisconnect", { reason: `session-shutdown:${reason}` }, { user, skipTxSafety: true }).catch(() => {});
|
|
|
|
await revokeOpenWebRxAccess(user, reason);
|
|
await executeCapability("station.deactivate", "deactivate", { userEmail: user.email }, { user });
|
|
}
|
|
|
|
async function revokeOpenWebRxAccess(user, reason) {
|
|
const provider = runtime.pluginState.providers["openwebrx.access.issue"];
|
|
if (!provider) {
|
|
return;
|
|
}
|
|
try {
|
|
await executeCapability("openwebrx.access.issue", "revokeOwner", {
|
|
ownerUserId: runtime.station.activeByUserId,
|
|
reason
|
|
}, { user, skipTxSafety: true });
|
|
} catch {
|
|
// optional capability
|
|
}
|
|
try {
|
|
await executeCapability("openwebrx.service.control", "serviceStop", { reason }, { user, skipTxSafety: true });
|
|
} catch {
|
|
// optional capability
|
|
}
|
|
clearOpenWebRxSession();
|
|
await clearStationAccessPolicyOwner(user);
|
|
}
|
|
|
|
async function handleOpenWebRxSessionIssue(res, user) {
|
|
if (!hasRole(user, ["operator", "approver", "admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
|
|
}
|
|
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
|
|
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX nur fuer den aktiven Stationsbenutzer");
|
|
}
|
|
const hasProvider = runtime.pluginState.providers["openwebrx.access.issue"];
|
|
if (!hasProvider) {
|
|
return sendError(res, 409, "openwebrx.not_configured", "OpenWebRX Provider nicht konfiguriert");
|
|
}
|
|
|
|
await ensureOpenWebRxSdrPath(user, { force: true });
|
|
|
|
const result = await executeCapability("openwebrx.access.issue", "issueAccess", {
|
|
userId: user.id,
|
|
userEmail: user.email,
|
|
ownerUserId: runtime.station.activeByUserId,
|
|
stationEndsAt: runtime.station.endsAt
|
|
}, { user, skipTxSafety: true });
|
|
|
|
await syncStationAccessPolicyOwner(user, user.email);
|
|
|
|
try {
|
|
await executeCapability("openwebrx.service.control", "serviceStart", { reason: "session-issue" }, { user, skipTxSafety: true });
|
|
} catch {
|
|
// optional capability
|
|
}
|
|
|
|
markOpenWebRxSession(user, result);
|
|
await ensureOpenWebRxInitialAntennaRoute(user);
|
|
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
session: result
|
|
});
|
|
}
|
|
|
|
async function ensureOpenWebRxInitialAntennaRoute(user) {
|
|
if (runtime.pttActive) {
|
|
return;
|
|
}
|
|
const rfrouteProvider = runtime.pluginState.providers["rfroute.set"];
|
|
if (!rfrouteProvider) {
|
|
return;
|
|
}
|
|
const bandState = await readOpenWebRxBandState(user);
|
|
const antennaRoute = normalizeAntennaRoute(bandState && bandState.antennaRoute ? bandState.antennaRoute : "");
|
|
if (!antennaRoute) {
|
|
return;
|
|
}
|
|
try {
|
|
await executeCapability("rfroute.set", "setRoute", { route: antennaRoute }, { user, skipTxSafety: true });
|
|
runtime.openWebRxAntennaRoute = antennaRoute;
|
|
runtime.txFollowRoute = antennaRoute;
|
|
} catch {
|
|
// keep session issue resilient; antenna follow is best effort
|
|
}
|
|
}
|
|
|
|
async function handleOpenWebRxBands(res, user) {
|
|
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
|
|
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX Baender nur fuer aktiven Stationsbenutzer");
|
|
}
|
|
const provider = runtime.pluginState.providers["openwebrx.band.read"];
|
|
if (!provider) {
|
|
return sendJson(res, 200, { ok: true, bands: [], selectedBand: null });
|
|
}
|
|
const result = await executeCapability("openwebrx.band.read", "getBands", {}, { user, skipTxSafety: true });
|
|
return sendJson(res, 200, { ok: true, ...result });
|
|
}
|
|
|
|
async function handleOpenWebRxBandSelect(res, user, body) {
|
|
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
|
|
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX Bandwechsel nur fuer aktiven Stationsbenutzer");
|
|
}
|
|
const provider = runtime.pluginState.providers["openwebrx.band.set"];
|
|
if (!provider) {
|
|
return sendError(res, 409, "openwebrx.band.not_configured", "OpenWebRX Band Plugin nicht konfiguriert");
|
|
}
|
|
const band = String(body && body.band ? body.band : "").trim();
|
|
if (!band) {
|
|
return sendError(res, 400, "openwebrx.band.missing", "Band fehlt");
|
|
}
|
|
if (runtime.pttActive) {
|
|
return sendError(res, 409, "openwebrx.ptt.active", "Bandwechsel waehrend aktivem PTT ist gesperrt");
|
|
}
|
|
try {
|
|
const result = await executeCapability("openwebrx.band.set", "setBand", { band }, { user, skipTxSafety: true });
|
|
const antennaRoute = normalizeAntennaRoute(result && result.antennaRoute);
|
|
if (!antennaRoute) {
|
|
return sendError(res, 409, "band.route_missing", `Keine antennaRoute fuer Band ${band} konfiguriert`);
|
|
}
|
|
const rfrouteProvider = runtime.pluginState.providers["rfroute.set"];
|
|
if (!rfrouteProvider) {
|
|
return sendError(res, 409, "rfroute.not_configured", "RFROUTE Plugin nicht konfiguriert");
|
|
}
|
|
await executeCapability("rfroute.set", "setRoute", { route: antennaRoute }, { user, skipTxSafety: true });
|
|
runtime.txFollowRoute = antennaRoute;
|
|
runtime.openWebRxAntennaRoute = antennaRoute;
|
|
await appendAudit("openwebrx.band.set", user, { band, result: { centerFreqHz: result.centerFreqHz || null, skipped: Boolean(result.skipped) } });
|
|
return sendJson(res, 200, { ok: true, result });
|
|
} catch (error) {
|
|
const message = String(error && error.message ? error.message : error);
|
|
if ((error && error.code === "ENOENT") || message.toLowerCase().includes("config_webrx.py")) {
|
|
return sendError(res, 409, "openwebrx.band.config_missing", "Bandwechsel-Config fehlt (OPENWEBRX_CONFIG_PATH/SET_CMD pruefen)");
|
|
}
|
|
return sendError(res, 409, "openwebrx.band.set_failed", message);
|
|
}
|
|
}
|
|
|
|
async function handleOpenWebRxTxEnable(res, user) {
|
|
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
|
|
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX TX nur fuer aktiven Stationsbenutzer");
|
|
}
|
|
try {
|
|
const result = await executeCapability("tx.control", "enableTx", { source: "openwebrx" }, { user, skipTxSafety: true });
|
|
if (!runtime.pttActive && runtime.pluginState.providers["rfroute.set"]) {
|
|
await executeCapability("rfroute.set", "setRoute", { route: "rx" }, { user, skipTxSafety: true, allowPttOverride: true });
|
|
runtime.txFollowRoute = "rx";
|
|
}
|
|
await appendAudit("tx.enable", user, { source: "openwebrx" });
|
|
return sendJson(res, 200, { ok: true, result });
|
|
} catch (error) {
|
|
return sendError(res, 409, "tx.enable_failed", String(error && error.message ? error.message : error));
|
|
}
|
|
}
|
|
|
|
async function handleOpenWebRxTxDisable(res, user) {
|
|
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
|
|
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX TX nur fuer aktiven Stationsbenutzer");
|
|
}
|
|
if (runtime.pttActive && runtime.pluginState.providers["rfroute.set"]) {
|
|
try {
|
|
await executeCapability("microham.ptt", "pttUp", {}, { user, skipTxSafety: true });
|
|
} catch (error) {
|
|
return sendError(res, 409, "openwebrx.ptt.command_failed", String(error && error.message ? error.message : error));
|
|
}
|
|
await executeCapability("rfroute.set", "setRoute", { route: "rx" }, { user, skipTxSafety: true, allowPttOverride: true });
|
|
runtime.pttActive = false;
|
|
runtime.txFollowRoute = "rx";
|
|
}
|
|
try {
|
|
const result = await executeCapability("tx.control", "disableTx", { source: "openwebrx-ptt" }, { user, skipTxSafety: true });
|
|
await appendAudit("tx.disable", user, { source: "openwebrx-ptt" });
|
|
return sendJson(res, 200, { ok: true, result });
|
|
} catch (error) {
|
|
return sendError(res, 409, "tx.disable_failed", String(error && error.message ? error.message : error));
|
|
}
|
|
}
|
|
|
|
async function handleOpenWebRxPttDown(res, user, input = {}) {
|
|
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
|
|
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX PTT nur fuer aktiven Stationsbenutzer");
|
|
}
|
|
const provider = runtime.pluginState.providers["rfroute.set"];
|
|
if (!provider) {
|
|
return sendError(res, 409, "rfroute.not_configured", "RFROUTE Plugin nicht konfiguriert");
|
|
}
|
|
if (runtime.pttActive) {
|
|
return sendJson(res, 200, { ok: true, result: { message: "PTT bereits aktiv" } });
|
|
}
|
|
const txState = await executeCapability("tx.state.read", "getTxState", {}, { user, skipTxSafety: true });
|
|
if (!txState || !txState.txActive) {
|
|
return sendError(res, 409, "tx.not_enabled", "TX ist nicht aktiviert");
|
|
}
|
|
|
|
const bandState = await readOpenWebRxBandState(user);
|
|
const selectedBandConfigId = String(bandState && (bandState.selectedBandConfigId || bandState.selectedBand) ? (bandState.selectedBandConfigId || bandState.selectedBand) : "").trim();
|
|
if (isPttBlockedForBandConfigId(selectedBandConfigId)) {
|
|
return sendError(res, 409, "openwebrx.ptt.band_config_blocked", "PTT fuer dieses Band ist gesperrt");
|
|
}
|
|
const clientLiveState = parseOpenWebRxLiveState(input || {});
|
|
if (!clientLiveState.frequencyHz || !clientLiveState.mode) {
|
|
return sendError(res, 409, "openwebrx.ptt.live_state_missing", "PTT gesperrt: Live-Frequenz/Mode fehlt");
|
|
}
|
|
const serverLiveState = getOpenWebRxLiveStateForUser(user.id);
|
|
if (!serverLiveState) {
|
|
return sendError(res, 409, "openwebrx.ptt.live_state_missing", "PTT gesperrt: Server-Live-State fehlt");
|
|
}
|
|
if (!openWebRxLiveStatesMatch(clientLiveState, serverLiveState)) {
|
|
return sendError(res, 409, "openwebrx.ptt.live_state_mismatch", "PTT gesperrt: Live-Daten stimmen nicht ueberein");
|
|
}
|
|
bandState.centerFreqHz = clientLiveState.frequencyHz;
|
|
bandState.startMod = clientLiveState.mode;
|
|
const antennaRoute = normalizeAntennaRoute(bandState.antennaRoute);
|
|
if (!antennaRoute) {
|
|
return sendError(res, 409, "band.route_missing", "Antenne fuer aktuelles Band ist nicht konfiguriert");
|
|
}
|
|
|
|
await executeCapability("rfroute.set", "setRoute", { route: antennaRoute }, { user, skipTxSafety: true });
|
|
runtime.openWebRxAntennaRoute = antennaRoute;
|
|
await executeCapability("rfroute.set", "setRoute", { route: "tx" }, { user, skipTxSafety: true });
|
|
let result;
|
|
try {
|
|
result = await executeCapability("microham.ptt", "pttDown", {
|
|
bandState
|
|
}, { user, skipTxSafety: true });
|
|
} catch (error) {
|
|
return sendError(res, 409, "openwebrx.ptt.command_failed", String(error && error.message ? error.message : error));
|
|
}
|
|
runtime.pttActive = true;
|
|
runtime.txFollowRoute = "tx";
|
|
await appendAudit("openwebrx.ptt.down", user, { result: result && result.direction ? result.direction : "down" });
|
|
return sendJson(res, 200, { ok: true, result });
|
|
}
|
|
|
|
async function handleOpenWebRxPttUp(res, user) {
|
|
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
|
|
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX PTT nur fuer aktiven Stationsbenutzer");
|
|
}
|
|
const provider = runtime.pluginState.providers["rfroute.set"];
|
|
if (!provider) {
|
|
return sendError(res, 409, "rfroute.not_configured", "RFROUTE Plugin nicht konfiguriert");
|
|
}
|
|
if (!runtime.pttActive) {
|
|
return sendJson(res, 200, { ok: true, result: { message: "PTT bereits inaktiv" } });
|
|
}
|
|
try {
|
|
await executeCapability("microham.ptt", "pttUp", {}, { user, skipTxSafety: true });
|
|
} catch (error) {
|
|
return sendError(res, 409, "openwebrx.ptt.command_failed", String(error && error.message ? error.message : error));
|
|
}
|
|
const result = await executeCapability("rfroute.set", "setRoute", { route: "rx" }, { user, skipTxSafety: true, allowPttOverride: true });
|
|
runtime.pttActive = false;
|
|
runtime.txFollowRoute = "rx";
|
|
await appendAudit("openwebrx.ptt.up", user, { result: result && result.message ? result.message : null });
|
|
return sendJson(res, 200, { ok: true, result });
|
|
}
|
|
|
|
async function handleOpenWebRxTxStatus(res, user) {
|
|
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
|
|
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX TX Status nur fuer aktiven Stationsbenutzer");
|
|
}
|
|
const state = await executeCapability("tx.state.read", "getTxState", {}, { user, skipTxSafety: true });
|
|
await maybeFollowTxRoute(user, Boolean(state && state.txActive));
|
|
let pttConfigured = false;
|
|
try {
|
|
const pttState = await executeCapability("microham.ptt", "pttStatus", {}, { user, skipTxSafety: true });
|
|
pttConfigured = Boolean(pttState && pttState.commandConfigured);
|
|
} catch {
|
|
pttConfigured = false;
|
|
}
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
txActive: Boolean(state && state.txActive),
|
|
powerCommandConfigured: isPowerControlConfigured(),
|
|
pttCommandConfigured: pttConfigured,
|
|
state: state || null
|
|
});
|
|
}
|
|
|
|
async function handleOpenWebRxRotorStatus(res, user) {
|
|
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
|
|
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX Rotorstatus nur fuer aktiven Stationsbenutzer");
|
|
}
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
rotor: normalizeRotorPayload(runtime.rotor)
|
|
});
|
|
}
|
|
|
|
async function handleOpenWebRxRotorSet(res, user, body, source = "api") {
|
|
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
|
|
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX Rotorsteuerung nur fuer aktiven Stationsbenutzer");
|
|
}
|
|
if (!rotorSetEnabled()) {
|
|
return sendError(res, 409, "rotor.set_disabled", "Rotorsteuerung ist derzeit deaktiviert");
|
|
}
|
|
const trigger = String(body && body.trigger ? body.trigger : "").trim().toLowerCase();
|
|
if (trigger !== "user") {
|
|
return sendError(res, 409, "rotor.set_guard", "Rotor-Set nur nach expliziter Benutzeraktion erlaubt");
|
|
}
|
|
const targetRaw = body && (body.target ?? body.azimuth ?? body.deg);
|
|
if (targetRaw === "" || targetRaw === null || targetRaw === undefined) {
|
|
return sendError(res, 400, "rotor.target.invalid", "target muss zwischen 0 und 360 sein");
|
|
}
|
|
const target = Number(targetRaw);
|
|
if (!Number.isFinite(target) || target < 0 || target > 360) {
|
|
return sendError(res, 400, "rotor.target.invalid", "target muss zwischen 0 und 360 sein");
|
|
}
|
|
|
|
const enqueueResult = enqueueRotorTarget(target, user, source);
|
|
void processRotorQueue();
|
|
await appendAudit("openwebrx.rotor.set.requested", user, {
|
|
source,
|
|
target: enqueueResult.pendingTarget,
|
|
queued: enqueueResult.queued,
|
|
replacedPending: enqueueResult.replacedPending
|
|
});
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
accepted: true,
|
|
queued: enqueueResult.queued,
|
|
replacedPending: enqueueResult.replacedPending,
|
|
result: {
|
|
message: enqueueResult.queued ? "Rotorziel angenommen (queued)" : "Rotorziel angenommen"
|
|
},
|
|
rotor: normalizeRotorPayload(runtime.rotor)
|
|
});
|
|
}
|
|
|
|
function enqueueRotorTarget(target, user, source) {
|
|
const normalizedTarget = normalizeRotorAzimuth(target);
|
|
if (normalizedTarget === null) {
|
|
throw new Error("target muss zwischen 0 und 360 sein");
|
|
}
|
|
const hadPending = getPendingRotorTarget() !== null;
|
|
runtime.rotor.pendingTargetAzimuth = normalizedTarget;
|
|
runtime.rotor.pendingRequestedByUserId = user && user.id ? String(user.id) : null;
|
|
runtime.rotor.pendingSource = String(source || "api");
|
|
runtime.rotor.phase = runtime.rotor.commandInProgress ? "queued" : "accepted";
|
|
runtime.rotor.updatedAt = new Date().toISOString();
|
|
return {
|
|
queued: Boolean(runtime.rotor.commandInProgress || runtime.rotor.queueWorkerActive),
|
|
replacedPending: hadPending,
|
|
pendingTarget: normalizedTarget
|
|
};
|
|
}
|
|
|
|
async function processRotorQueue() {
|
|
if (runtime.rotor.queueWorkerActive) {
|
|
return;
|
|
}
|
|
runtime.rotor.queueWorkerActive = true;
|
|
try {
|
|
while (getPendingRotorTarget() !== null) {
|
|
const target = Number(getPendingRotorTarget());
|
|
const requestedByUserId = runtime.rotor.pendingRequestedByUserId;
|
|
const source = runtime.rotor.pendingSource || "api";
|
|
runtime.rotor.pendingTargetAzimuth = null;
|
|
runtime.rotor.pendingRequestedByUserId = null;
|
|
runtime.rotor.pendingSource = null;
|
|
runtime.rotor.phase = "executing";
|
|
|
|
try {
|
|
const result = await setNativeRotorAzimuth(target);
|
|
runtime.rotor.lastResult = result && result.message ? String(result.message) : `Rotor auf ${Math.round(target)} Grad gesetzt`;
|
|
runtime.rotor.lastError = null;
|
|
const auditUser = requestedByUserId
|
|
? (runtime.users.find((entry) => String(entry.id) === String(requestedByUserId)) || { id: String(requestedByUserId), email: "openwebrx-owner", role: "operator" })
|
|
: null;
|
|
await appendAudit("openwebrx.rotor.set", auditUser, { source, target, result: runtime.rotor.lastResult });
|
|
} catch (error) {
|
|
runtime.rotor.lastError = String(error && error.message ? error.message : error);
|
|
runtime.rotor.lastResult = null;
|
|
}
|
|
|
|
runtime.rotor.updatedAt = new Date().toISOString();
|
|
runtime.rotor.phase = getPendingRotorTarget() !== null ? "queued" : "idle";
|
|
}
|
|
} finally {
|
|
runtime.rotor.queueWorkerActive = false;
|
|
if (!runtime.rotor.commandInProgress && getPendingRotorTarget() === null) {
|
|
runtime.rotor.phase = "idle";
|
|
}
|
|
}
|
|
}
|
|
|
|
async function readOpenWebRxBandState(user) {
|
|
const provider = runtime.pluginState.providers["openwebrx.band.read"];
|
|
if (!provider) {
|
|
return { selectedBandConfigId: null, selectedBand: null, antennaRoute: "", centerFreqHz: null, startMod: null };
|
|
}
|
|
|
|
try {
|
|
const state = await executeCapability("openwebrx.band.read", "getState", {}, { user, skipTxSafety: true });
|
|
if (state && typeof state === "object") {
|
|
return {
|
|
selectedBandConfigId: String(state.selectedBand || "").trim() || null,
|
|
selectedBand: String(state.selectedBand || "").trim() || null,
|
|
antennaRoute: String(state.antennaRoute || "").trim().toLowerCase(),
|
|
centerFreqHz: Number.isFinite(Number(state.centerFreqHz)) ? Number(state.centerFreqHz) : null,
|
|
startMod: String(state.startMod || "").trim().toLowerCase() || null
|
|
};
|
|
}
|
|
} catch {
|
|
// fallback to getBands
|
|
}
|
|
|
|
const result = await executeCapability("openwebrx.band.read", "getBands", {}, { user, skipTxSafety: true });
|
|
const selectedBandConfigId = String(result && result.selectedBand ? result.selectedBand : "").trim() || null;
|
|
let antennaRoute = "";
|
|
let centerFreqHz = null;
|
|
let startMod = null;
|
|
const bands = Array.isArray(result && result.bands) ? result.bands : [];
|
|
if (selectedBandConfigId) {
|
|
const selected = bands.find((entry) => {
|
|
if (!entry || typeof entry !== "object") {
|
|
return false;
|
|
}
|
|
return String(entry.band || entry.id || "").trim() === selectedBandConfigId;
|
|
});
|
|
if (selected && typeof selected === "object") {
|
|
antennaRoute = String(selected.antennaRoute || "").trim().toLowerCase();
|
|
centerFreqHz = Number.isFinite(Number(selected.centerFreqHz)) ? Number(selected.centerFreqHz) : null;
|
|
startMod = String(selected.startMod || "").trim().toLowerCase() || null;
|
|
}
|
|
}
|
|
return { selectedBandConfigId, selectedBand: selectedBandConfigId, antennaRoute, centerFreqHz, startMod };
|
|
}
|
|
|
|
function normalizeAntennaRoute(value) {
|
|
const route = String(value || "").trim().toLowerCase();
|
|
if (!route) {
|
|
return "";
|
|
}
|
|
const allowed = new Set(["draht", "beam", "wrtc"]);
|
|
return allowed.has(route) ? route : "";
|
|
}
|
|
|
|
function normalizeLiveMode(value) {
|
|
const raw = String(value || "").trim().toLowerCase();
|
|
if (!raw) {
|
|
return "";
|
|
}
|
|
const map = {
|
|
usb: "usb",
|
|
lsb: "lsb",
|
|
am: "am",
|
|
fm: "fm",
|
|
nfm: "fm",
|
|
wfm: "fm",
|
|
cw: "cw",
|
|
cwr: "cwr"
|
|
};
|
|
return map[raw] || "";
|
|
}
|
|
|
|
function parseOpenWebRxLiveState(input) {
|
|
const frequencyHz = Number(input && input.liveFrequencyHz);
|
|
const normalizedFrequency = Number.isFinite(frequencyHz) && frequencyHz > 0
|
|
? Math.floor(frequencyHz)
|
|
: null;
|
|
const mode = normalizeLiveMode(input && input.liveMode);
|
|
return {
|
|
frequencyHz: normalizedFrequency,
|
|
mode
|
|
};
|
|
}
|
|
|
|
function setOpenWebRxLiveStateForUser(userId, state) {
|
|
const key = String(userId || "").trim();
|
|
if (!key) {
|
|
return;
|
|
}
|
|
runtime.openWebRxLiveStateByUserId[key] = {
|
|
frequencyHz: Number.isFinite(Number(state && state.frequencyHz)) ? Math.floor(Number(state.frequencyHz)) : null,
|
|
mode: normalizeLiveMode(state && state.mode),
|
|
updatedAtMs: Number.isFinite(Number(state && state.updatedAtMs)) ? Number(state.updatedAtMs) : Date.now(),
|
|
source: String(state && state.source ? state.source : "unknown")
|
|
};
|
|
}
|
|
|
|
function getOpenWebRxLiveStateForUser(userId) {
|
|
const key = String(userId || "").trim();
|
|
if (!key) {
|
|
return null;
|
|
}
|
|
const state = runtime.openWebRxLiveStateByUserId[key];
|
|
if (!state || typeof state !== "object") {
|
|
return null;
|
|
}
|
|
const now = Date.now();
|
|
const ttlMs = Number.isFinite(config.openWebRxLiveStateTtlMs) ? Math.max(1000, config.openWebRxLiveStateTtlMs) : 10000;
|
|
if (!Number.isFinite(Number(state.updatedAtMs)) || now - Number(state.updatedAtMs) > ttlMs) {
|
|
delete runtime.openWebRxLiveStateByUserId[key];
|
|
return null;
|
|
}
|
|
const frequencyHz = Number.isFinite(Number(state.frequencyHz)) ? Math.floor(Number(state.frequencyHz)) : null;
|
|
const mode = normalizeLiveMode(state.mode);
|
|
if (!frequencyHz || !mode) {
|
|
return null;
|
|
}
|
|
return {
|
|
frequencyHz,
|
|
mode,
|
|
updatedAtMs: Number(state.updatedAtMs),
|
|
source: String(state.source || "unknown")
|
|
};
|
|
}
|
|
|
|
function openWebRxLiveStatesMatch(clientState, serverState) {
|
|
if (!clientState || !serverState) {
|
|
return false;
|
|
}
|
|
const clientMode = normalizeLiveMode(clientState.mode);
|
|
const serverMode = normalizeLiveMode(serverState.mode);
|
|
if (!clientMode || !serverMode || clientMode !== serverMode) {
|
|
return false;
|
|
}
|
|
const a = Number(clientState.frequencyHz);
|
|
const b = Number(serverState.frequencyHz);
|
|
if (!Number.isFinite(a) || !Number.isFinite(b)) {
|
|
return false;
|
|
}
|
|
const tolerance = Number.isFinite(config.openWebRxPttLiveFreqToleranceHz)
|
|
? Math.max(0, Math.floor(config.openWebRxPttLiveFreqToleranceHz))
|
|
: 50;
|
|
return Math.abs(Math.floor(a) - Math.floor(b)) <= tolerance;
|
|
}
|
|
|
|
function openWebRxPttCommandsEnabled() {
|
|
return String(config.openWebRxPttCommandsEnabled || "false").trim().toLowerCase() === "true";
|
|
}
|
|
|
|
function rotorSetEnabled() {
|
|
return String(config.rotorSetEnabled || "true").trim().toLowerCase() !== "false";
|
|
}
|
|
|
|
function renderOpenWebRxPttCommand(template) {
|
|
const raw = String(template || "").trim();
|
|
if (!raw) {
|
|
return "";
|
|
}
|
|
const pttDevice = String(config.openWebRxPttDevice || process.env.RMS_MICROHAM_DEV || "/dev/rms-microham-u3").trim() || "/dev/rms-microham-u3";
|
|
if (!/^\/dev\/[A-Za-z0-9._\/-]+$/.test(pttDevice)) {
|
|
throw new Error(`invalid ptt device path: ${pttDevice}`);
|
|
}
|
|
return raw.replaceAll("{pttDevice}", pttDevice);
|
|
}
|
|
|
|
async function runConfiguredOpenWebRxPttCommand(direction) {
|
|
if (!openWebRxPttCommandsEnabled()) {
|
|
throw new Error("OPENWEBRX_PTT_COMMANDS_ENABLED must be true");
|
|
}
|
|
const normalized = String(direction || "").trim().toLowerCase();
|
|
if (normalized !== "down" && normalized !== "up") {
|
|
throw new Error(`invalid ptt direction: ${normalized}`);
|
|
}
|
|
const commandTemplate = normalized === "down" ? config.openWebRxPttDownCmd : config.openWebRxPttUpCmd;
|
|
const command = renderOpenWebRxPttCommand(commandTemplate);
|
|
if (!command) {
|
|
throw new Error(normalized === "down"
|
|
? "OPENWEBRX_PTT_DOWN_CMD missing"
|
|
: "OPENWEBRX_PTT_UP_CMD missing");
|
|
}
|
|
const result = await runCommand(command, {
|
|
timeoutMs: Number.isFinite(Number(config.openWebRxPttTimeoutMs)) ? Number(config.openWebRxPttTimeoutMs) : 5000
|
|
});
|
|
if (!result.ok) {
|
|
throw new Error(result.stderr || result.error || `ptt ${normalized} command failed`);
|
|
}
|
|
return { ok: true, skipped: false, direction: normalized };
|
|
}
|
|
|
|
function normalizeRotorPayload(result) {
|
|
const pendingTarget = getPendingRotorTarget();
|
|
const activeTarget = (runtime.rotor.targetAzimuth === null || runtime.rotor.targetAzimuth === undefined)
|
|
? null
|
|
: (Number.isFinite(Number(runtime.rotor.targetAzimuth)) ? Number(runtime.rotor.targetAzimuth) : null);
|
|
const queueDepth = runtime.rotor.commandInProgress
|
|
? (pendingTarget !== null ? 2 : 1)
|
|
: (pendingTarget !== null ? 1 : 0);
|
|
return {
|
|
azimuth: (result && result.azimuth !== null && result.azimuth !== undefined && result.azimuth !== "" && Number.isFinite(Number(result.azimuth)))
|
|
? Number(result.azimuth)
|
|
: null,
|
|
rawAzimuth: (result && result.rawAzimuth !== null && result.rawAzimuth !== undefined && result.rawAzimuth !== "" && Number.isFinite(Number(result.rawAzimuth)))
|
|
? Number(result.rawAzimuth)
|
|
: null,
|
|
moving: Boolean(result && result.moving),
|
|
stale: Boolean(result && result.stale),
|
|
phase: String(runtime.rotor.phase || "idle"),
|
|
queueDepth,
|
|
activeTarget,
|
|
pendingTarget,
|
|
lastResult: runtime.rotor.lastResult ? String(runtime.rotor.lastResult) : null,
|
|
lastError: runtime.rotor.lastError ? String(runtime.rotor.lastError) : null,
|
|
updatedAt: result && result.updatedAt ? String(result.updatedAt) : null,
|
|
error: result && result.error ? String(result.error) : null,
|
|
min: Number.isFinite(Number(result && result.min)) ? Number(result.min) : 0,
|
|
max: Number.isFinite(Number(result && result.max)) ? Number(result.max) : 360
|
|
};
|
|
}
|
|
|
|
function getPendingRotorTarget() {
|
|
if (runtime.rotor.pendingTargetAzimuth === null
|
|
|| runtime.rotor.pendingTargetAzimuth === undefined
|
|
|| runtime.rotor.pendingTargetAzimuth === "") {
|
|
return null;
|
|
}
|
|
const pending = Number(runtime.rotor.pendingTargetAzimuth);
|
|
return Number.isFinite(pending) ? pending : null;
|
|
}
|
|
|
|
function normalizeRotorAzimuth(value) {
|
|
if (!Number.isFinite(Number(value))) {
|
|
return null;
|
|
}
|
|
let az = Number(value);
|
|
while (az < 0) az += 360;
|
|
while (az >= 360) az -= 360;
|
|
return az;
|
|
}
|
|
|
|
function circularAzimuthDistance(a, b) {
|
|
const left = normalizeRotorAzimuth(a);
|
|
const right = normalizeRotorAzimuth(b);
|
|
if (left === null || right === null) {
|
|
return Number.POSITIVE_INFINITY;
|
|
}
|
|
const diff = Math.abs(left - right);
|
|
return Math.min(diff, 360 - diff);
|
|
}
|
|
|
|
function waitMs(ms) {
|
|
const delay = Number.isFinite(Number(ms)) ? Math.max(0, Math.floor(Number(ms))) : 0;
|
|
return new Promise((resolve) => setTimeout(resolve, delay));
|
|
}
|
|
|
|
function isHardRotorSetFailure(result) {
|
|
if (!result || typeof result !== "object") {
|
|
return true;
|
|
}
|
|
if (Number(result.code) === -1) {
|
|
return true;
|
|
}
|
|
const msg = `${result.stderr || ""}\n${result.error || ""}`.toLowerCase();
|
|
if (!msg.trim()) {
|
|
return false;
|
|
}
|
|
const hardPatterns = [
|
|
"no such file",
|
|
"not found",
|
|
"permission denied",
|
|
"cannot open",
|
|
"failed to open",
|
|
"unable to open",
|
|
"device not found"
|
|
];
|
|
return hardPatterns.some((pattern) => msg.includes(pattern));
|
|
}
|
|
|
|
function rotorCommandBaseArgs() {
|
|
const model = Number.isFinite(Number(config.rotorModel)) ? Math.floor(Number(config.rotorModel)) : 902;
|
|
const device = resolveRotorDevicePath();
|
|
return `-m ${model} -r ${device} -Z --set-conf=post_write_delay=0`;
|
|
}
|
|
|
|
function resolveRotorDevicePath() {
|
|
const device = String(config.rotorDevice || "/dev/rms-ftdi-uart").trim() || "/dev/rms-ftdi-uart";
|
|
if (!/^\/dev\/[A-Za-z0-9._\/-]+$/.test(device)) {
|
|
throw new Error(`invalid rotor device path: ${device}`);
|
|
}
|
|
return device;
|
|
}
|
|
|
|
async function readNativeRotorStatus() {
|
|
if (config.simulateHardware) {
|
|
return {
|
|
azimuth: runtime.rotor.azimuth,
|
|
rawAzimuth: runtime.rotor.rawAzimuth,
|
|
moving: Boolean(runtime.rotor.moving),
|
|
min: 0,
|
|
max: 360,
|
|
simulated: true
|
|
};
|
|
}
|
|
|
|
const configuredAttempts = Number.isFinite(Number(config.rotorStatusRetryCount))
|
|
? Math.max(1, Math.floor(Number(config.rotorStatusRetryCount)))
|
|
: 1;
|
|
const attempts = (runtime.rotor.commandInProgress || runtime.rotor.queueWorkerActive)
|
|
? 1
|
|
: configuredAttempts;
|
|
const retryDelayMs = (runtime.rotor.commandInProgress || runtime.rotor.queueWorkerActive)
|
|
? 0
|
|
: (Number.isFinite(Number(config.rotorStatusRetryDelayMs))
|
|
? Math.max(0, Math.floor(Number(config.rotorStatusRetryDelayMs)))
|
|
: 0);
|
|
|
|
let lastError = "";
|
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
const command = `rotctl ${rotorCommandBaseArgs()} p p`;
|
|
const result = await runCommand(command, {
|
|
timeoutMs: Number.isFinite(Number(config.rotorGetTimeoutMs)) ? Number(config.rotorGetTimeoutMs) : 10000
|
|
});
|
|
if (result.ok) {
|
|
const parsed = parseRotorAzimuthFromOutput(result.stdout || "");
|
|
const rawAzimuth = Number.isFinite(parsed) ? parsed : null;
|
|
const azimuth = normalizeRotorAzimuth(parsed);
|
|
if (azimuth !== null) {
|
|
runtime.rotor.rawAzimuth = rawAzimuth;
|
|
runtime.rotor.azimuth = azimuth;
|
|
runtime.rotor.updatedAt = new Date().toISOString();
|
|
return {
|
|
azimuth: runtime.rotor.azimuth,
|
|
rawAzimuth: runtime.rotor.rawAzimuth,
|
|
moving: Boolean(runtime.rotor.moving),
|
|
min: 0,
|
|
max: 360,
|
|
updatedAt: runtime.rotor.updatedAt
|
|
};
|
|
}
|
|
lastError = "rotctl get returned invalid azimuth";
|
|
} else {
|
|
lastError = result.stderr || result.error || "rotctl get failed";
|
|
}
|
|
|
|
if (attempt < attempts && retryDelayMs > 0) {
|
|
await waitMs(retryDelayMs);
|
|
}
|
|
}
|
|
|
|
if (runtime.rotor.azimuth !== null) {
|
|
return {
|
|
azimuth: runtime.rotor.azimuth,
|
|
rawAzimuth: runtime.rotor.rawAzimuth,
|
|
moving: Boolean(runtime.rotor.moving),
|
|
min: 0,
|
|
max: 360,
|
|
updatedAt: runtime.rotor.updatedAt,
|
|
stale: true,
|
|
error: lastError || "rotctl get unavailable"
|
|
};
|
|
}
|
|
|
|
throw new Error(lastError || "rotctl get failed");
|
|
}
|
|
|
|
async function refreshRotorStatusCache() {
|
|
if (config.simulateHardware) {
|
|
return;
|
|
}
|
|
if (runtime.rotor.commandInProgress || runtime.rotor.queueWorkerActive || runtime.rotor.statusRefreshInFlight) {
|
|
return;
|
|
}
|
|
runtime.rotor.statusRefreshInFlight = true;
|
|
try {
|
|
await readNativeRotorStatus();
|
|
} catch {
|
|
// best effort background refresh
|
|
} finally {
|
|
runtime.rotor.statusRefreshInFlight = false;
|
|
}
|
|
}
|
|
|
|
function parseRotorAzimuthFromOutput(stdout) {
|
|
const lines = String(stdout || "")
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line && !/^rprt\b/i.test(line));
|
|
if (lines.length === 0) {
|
|
return Number.NaN;
|
|
}
|
|
|
|
for (const line of lines) {
|
|
const prefixed = line.match(/(?:azimuth|azi)\s*[:=]\s*([-+]?\d+(?:\.\d+)?)/i);
|
|
if (prefixed) {
|
|
const parsed = Number(prefixed[1]);
|
|
if (Number.isFinite(parsed) && parsed >= -360 && parsed <= 360) {
|
|
return parsed;
|
|
}
|
|
}
|
|
}
|
|
// For rotctl "p p", first numeric line is azimuth, second is elevation.
|
|
// If we only get one unlabeled numeric line (often elevation=0), do not accept it
|
|
// as azimuth to avoid false "0°" status updates.
|
|
const numericLines = lines.filter((line) => /^[-+]?\d+(?:\.\d+)?$/.test(line));
|
|
if (numericLines.length >= 2) {
|
|
const parsed = Number(numericLines[0]);
|
|
if (Number.isFinite(parsed) && parsed >= -360 && parsed <= 360) {
|
|
return parsed;
|
|
}
|
|
}
|
|
return Number.NaN;
|
|
}
|
|
|
|
async function waitForRotorStatusAfterSet(target, timeoutMs) {
|
|
const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
|
|
let lastStatus = null;
|
|
let lastError = null;
|
|
|
|
do {
|
|
try {
|
|
const status = await readNativeRotorStatus();
|
|
lastStatus = status;
|
|
if (!status || status.stale !== true) {
|
|
return status;
|
|
}
|
|
const distance = circularAzimuthDistance(status.azimuth, target);
|
|
if (Number.isFinite(distance) && distance <= 5) {
|
|
return status;
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
await waitMs(Math.max(100, Number(config.rotorStatusRetryDelayMs) || 300));
|
|
} while (Date.now() < deadline);
|
|
|
|
if (lastStatus) {
|
|
return lastStatus;
|
|
}
|
|
if (lastError) {
|
|
throw lastError;
|
|
}
|
|
throw new Error("rotor status unavailable after set");
|
|
}
|
|
|
|
async function setNativeRotorAzimuth(target) {
|
|
const normalizedTarget = normalizeRotorAzimuth(target);
|
|
if (normalizedTarget === null) {
|
|
throw new Error("target muss zwischen 0 und 360 sein");
|
|
}
|
|
if (runtime.rotor.commandInProgress) {
|
|
throw new Error("rotor command already in progress");
|
|
}
|
|
|
|
runtime.rotor.commandInProgress = true;
|
|
runtime.rotor.commandStartedAt = new Date().toISOString();
|
|
runtime.rotor.targetAzimuth = normalizedTarget;
|
|
runtime.rotor.moving = true;
|
|
runtime.rotor.phase = "setting";
|
|
runtime.rotor.lastChangeAt = Date.now();
|
|
|
|
if (config.simulateHardware) {
|
|
runtime.rotor.azimuth = normalizedTarget;
|
|
runtime.rotor.updatedAt = new Date().toISOString();
|
|
runtime.rotor.moving = false;
|
|
runtime.rotor.commandInProgress = false;
|
|
runtime.rotor.targetAzimuth = null;
|
|
runtime.rotor.commandStartedAt = null;
|
|
return {
|
|
ok: true,
|
|
message: `Rotor auf ${Math.round(normalizedTarget)} Grad simuliert`,
|
|
rotor: {
|
|
azimuth: runtime.rotor.azimuth,
|
|
moving: false,
|
|
min: 0,
|
|
max: 360
|
|
}
|
|
};
|
|
}
|
|
|
|
try {
|
|
let initialAzimuth = runtime.rotor.azimuth;
|
|
try {
|
|
const initialStatus = await readNativeRotorStatus();
|
|
if (Number.isFinite(Number(initialStatus && initialStatus.azimuth))) {
|
|
initialAzimuth = Number(initialStatus.azimuth);
|
|
}
|
|
} catch {
|
|
// continue with cached status
|
|
}
|
|
|
|
const setpoint = Number(normalizedTarget);
|
|
const command = `rotctl ${rotorCommandBaseArgs()} p p P ${Math.round(setpoint)} 0 p p`;
|
|
const result = await runCommand(command, {
|
|
timeoutMs: Number.isFinite(Number(config.rotorSetTimeoutMs)) ? Number(config.rotorSetTimeoutMs) : 20000
|
|
});
|
|
if (!result.ok && isHardRotorSetFailure(result)) {
|
|
throw new Error(result.stderr || result.error || "rotctl set failed");
|
|
}
|
|
runtime.rotor.phase = "monitoring";
|
|
const observed = await monitorRotorMoveToTarget(normalizedTarget, initialAzimuth);
|
|
return {
|
|
ok: true,
|
|
message: `Rotor auf ${Math.round(normalizedTarget)} Grad gesetzt`,
|
|
warning: result.ok ? null : (result.stderr || result.error || "rotctl set returned non-zero"),
|
|
rotor: normalizeRotorPayload(observed)
|
|
};
|
|
} finally {
|
|
runtime.rotor.moving = false;
|
|
runtime.rotor.commandInProgress = false;
|
|
runtime.rotor.targetAzimuth = null;
|
|
runtime.rotor.commandStartedAt = null;
|
|
if (getPendingRotorTarget() === null) {
|
|
runtime.rotor.phase = "idle";
|
|
}
|
|
}
|
|
}
|
|
|
|
async function monitorRotorMoveToTarget(targetAzimuth, initialAzimuth) {
|
|
const firstCheckDelayMs = 2000;
|
|
const pollMs = 1000;
|
|
const stallMs = 7000;
|
|
const configuredMaxMs = Number.isFinite(Number(config.rotorMonitorMaxMs))
|
|
? Math.max(15000, Number(config.rotorMonitorMaxMs))
|
|
: 120000;
|
|
const estimatedDistance = Number.isFinite(Number(initialAzimuth))
|
|
? circularAzimuthDistance(Number(initialAzimuth), targetAzimuth)
|
|
: Number.POSITIVE_INFINITY;
|
|
const distanceBudgetMs = Number.isFinite(estimatedDistance)
|
|
? Math.max(15000, Math.round(estimatedDistance * 450) + 12000)
|
|
: 30000;
|
|
const maxTotalMs = Math.max(configuredMaxMs, distanceBudgetMs);
|
|
|
|
await waitMs(firstCheckDelayMs);
|
|
|
|
const startAt = Date.now();
|
|
let lastAzimuth = Number.isFinite(Number(initialAzimuth)) ? Number(initialAzimuth) : null;
|
|
let movementDetected = false;
|
|
let lastMovementAt = Date.now();
|
|
let lastStatus = null;
|
|
let lastReadError = null;
|
|
|
|
while ((Date.now() - startAt) <= maxTotalMs) {
|
|
try {
|
|
const status = await readNativeRotorStatus();
|
|
lastStatus = status;
|
|
lastReadError = null;
|
|
const current = Number.isFinite(Number(status && status.azimuth)) ? Number(status.azimuth) : null;
|
|
if (current !== null) {
|
|
const toTarget = circularAzimuthDistance(current, targetAzimuth);
|
|
if (Number.isFinite(toTarget) && toTarget <= 5) {
|
|
runtime.rotor.azimuth = current;
|
|
runtime.rotor.updatedAt = new Date().toISOString();
|
|
return {
|
|
...status,
|
|
moving: false
|
|
};
|
|
}
|
|
if (lastAzimuth === null || circularAzimuthDistance(lastAzimuth, current) >= 1) {
|
|
movementDetected = true;
|
|
lastMovementAt = Date.now();
|
|
lastAzimuth = current;
|
|
}
|
|
runtime.rotor.azimuth = current;
|
|
runtime.rotor.updatedAt = new Date().toISOString();
|
|
}
|
|
} catch (error) {
|
|
lastReadError = error;
|
|
}
|
|
|
|
if (!movementDetected && (Date.now() - startAt) >= stallMs) {
|
|
throw new Error("rotor did not start moving after command");
|
|
}
|
|
if (movementDetected && (Date.now() - lastMovementAt) >= stallMs) {
|
|
throw new Error("rotor movement stalled before reaching target");
|
|
}
|
|
|
|
await waitMs(pollMs);
|
|
}
|
|
|
|
if (lastStatus) {
|
|
throw new Error("rotor did not reach target within timeout");
|
|
}
|
|
if (lastReadError) {
|
|
throw new Error(String(lastReadError && lastReadError.message ? lastReadError.message : lastReadError));
|
|
}
|
|
throw new Error("rotor status unavailable while monitoring movement");
|
|
}
|
|
|
|
async function maybeFollowTxRoute(user, txActive) {
|
|
if (runtime.pttActive) {
|
|
return;
|
|
}
|
|
if (txActive) {
|
|
return;
|
|
}
|
|
const provider = runtime.pluginState.providers["rfroute.set"];
|
|
if (!provider) {
|
|
return;
|
|
}
|
|
if (runtime.txFollowRoute !== "tx") {
|
|
return;
|
|
}
|
|
const desiredRoute = "rx";
|
|
if (runtime.txFollowRoute === desiredRoute) {
|
|
return;
|
|
}
|
|
try {
|
|
await executeCapability("rfroute.set", "setRoute", { route: desiredRoute }, { user, skipTxSafety: true });
|
|
runtime.txFollowRoute = desiredRoute;
|
|
} catch {
|
|
// rfroute follow is best effort
|
|
}
|
|
}
|
|
|
|
async function enforceOpenWebRxPttConnectionWatchdog() {
|
|
if (!runtime.pttActive) {
|
|
return;
|
|
}
|
|
const activeOwnerId = String(runtime.station && runtime.station.activeByUserId ? runtime.station.activeByUserId : "");
|
|
if (!activeOwnerId) {
|
|
await forceOpenWebRxPttUpForConnectionLoss(null, "missing-owner");
|
|
return;
|
|
}
|
|
const liveState = getOpenWebRxLiveStateForUser(activeOwnerId);
|
|
if (!liveState) {
|
|
await forceOpenWebRxPttUpForConnectionLoss(activeOwnerId, "missing-live-state");
|
|
return;
|
|
}
|
|
const staleAfterMs = Math.max(1500, Number(config.openWebRxLiveStateTtlMs || 10000));
|
|
const lastUpdatedMs = Number(liveState.updatedAtMs || 0);
|
|
if (!Number.isFinite(lastUpdatedMs) || (Date.now() - lastUpdatedMs) > staleAfterMs) {
|
|
await forceOpenWebRxPttUpForConnectionLoss(activeOwnerId, "stale-live-state");
|
|
}
|
|
}
|
|
|
|
async function forceOpenWebRxPttUpForConnectionLoss(ownerUserId, reason) {
|
|
return withLock(async () => {
|
|
if (!runtime.pttActive) {
|
|
return;
|
|
}
|
|
const actingUser = runtime.users.find((entry) => String(entry && entry.id ? entry.id : "") === String(ownerUserId || "")) || null;
|
|
try {
|
|
await executeCapability("microham.ptt", "pttUp", {}, { user: actingUser, skipTxSafety: true });
|
|
} catch {
|
|
// best effort
|
|
}
|
|
try {
|
|
await executeCapability("rfroute.set", "setRoute", { route: "rx" }, { user: actingUser, skipTxSafety: true, allowPttOverride: true });
|
|
} catch {
|
|
// best effort
|
|
}
|
|
runtime.pttActive = false;
|
|
runtime.txFollowRoute = "rx";
|
|
await appendAudit("openwebrx.ptt.auto_up", actingUser, { reason: reason || "connection-lost" });
|
|
broadcastEvent("station.status.changed", buildStationStatusView());
|
|
});
|
|
}
|
|
|
|
async function handleOpenWebRxSessionClose(res, user) {
|
|
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
|
|
return sendError(res, 403, "openwebrx.not_owner", "Nur aktiver Stationsbenutzer darf schliessen");
|
|
}
|
|
try {
|
|
if (runtime.pttActive && runtime.pluginState.providers["rfroute.set"]) {
|
|
try {
|
|
await executeCapability("microham.ptt", "pttUp", {}, { user, skipTxSafety: true });
|
|
} catch {
|
|
// best effort
|
|
}
|
|
try {
|
|
await executeCapability("rfroute.set", "setRoute", { route: "rx" }, { user, skipTxSafety: true, allowPttOverride: true });
|
|
} catch {
|
|
// best effort
|
|
}
|
|
runtime.pttActive = false;
|
|
runtime.txFollowRoute = "rx";
|
|
}
|
|
await executeCapability("tx.control", "disableTx", { source: "openwebrx-close" }, { user, skipTxSafety: true });
|
|
await executeCapability("tx.audio", "audioDisconnect", { reason: "openwebrx-close" }, { user, skipTxSafety: true }).catch(() => {});
|
|
await appendAudit("tx.disable", user, { source: "openwebrx-close" });
|
|
} catch (error) {
|
|
return sendError(res, 409, "tx.disable_failed", String(error && error.message ? error.message : error));
|
|
}
|
|
await revokeOpenWebRxAccess(user, "openwebrx-close");
|
|
clearOpenWebRxSession();
|
|
return sendJson(res, 200, { ok: true });
|
|
}
|
|
|
|
async function handleOpenWebRxPluginState(req, res, url) {
|
|
const auth = await requireOpenWebRxTicketUser(req, res, url);
|
|
if (!auth) {
|
|
return;
|
|
}
|
|
const user = auth.user;
|
|
|
|
const payload = {
|
|
ok: true,
|
|
txActive: false,
|
|
pttActive: Boolean(runtime.pttActive),
|
|
power: {
|
|
active: false,
|
|
commandConfigured: isPowerControlConfigured()
|
|
},
|
|
ptt: {
|
|
active: Boolean(runtime.pttActive),
|
|
commandConfigured: false,
|
|
providerAvailable: Boolean(runtime.pluginState.providers["microham.ptt"]),
|
|
providerId: runtime.pluginState.providers["microham.ptt"] || null,
|
|
providerEnabled: null,
|
|
liveStateReady: false,
|
|
liveState: null,
|
|
error: null,
|
|
blockedBandConfigIds: Array.isArray(config.openWebRxPttBlockedBandConfigIds)
|
|
? [...config.openWebRxPttBlockedBandConfigIds]
|
|
: [],
|
|
blockedByBand: false
|
|
},
|
|
rfPath: {
|
|
current: null,
|
|
commandConfigured: isRfrouteTxRxConfigured()
|
|
},
|
|
antenna: {
|
|
current: null,
|
|
commandConfigured: isAntennaRoutingConfigured()
|
|
},
|
|
stationEndsAt: runtime.station && runtime.station.endsAt ? runtime.station.endsAt : null,
|
|
remainingUsageSec: runtime.station && runtime.station.isInUse && runtime.station.endsAt
|
|
? Math.max(0, Math.ceil((new Date(runtime.station.endsAt).getTime() - Date.now()) / 1000))
|
|
: 0,
|
|
rfroute: {
|
|
current: null,
|
|
options: []
|
|
},
|
|
txAudio: {
|
|
enabled: isLikelyMicrohamAudioEnabledFromEnv(),
|
|
state: isLikelyMicrohamAudioEnabledFromEnv() ? "disconnected" : "disabled",
|
|
running: false,
|
|
clients: 0,
|
|
ownerUserId: null,
|
|
ownerMatchesCaller: false,
|
|
startedAt: null,
|
|
lastError: null,
|
|
lastExit: null,
|
|
ffmpegPath: null,
|
|
alsaDevice: null,
|
|
chunkMs: 100,
|
|
wsPath: "/v1/openwebrx/plugin/audio/ws"
|
|
},
|
|
bands: [],
|
|
selectedBand: null,
|
|
selectedBandConfigId: null,
|
|
antennaRoute: null,
|
|
rotor: {
|
|
azimuth: null,
|
|
moving: false,
|
|
min: 0,
|
|
max: 360
|
|
}
|
|
};
|
|
|
|
try {
|
|
const txState = await withTimeout(
|
|
executeCapability("tx.state.read", "getTxState", {}, { user, skipTxSafety: true }),
|
|
1200,
|
|
"tx.state.read timeout"
|
|
);
|
|
payload.txActive = Boolean(txState && txState.txActive);
|
|
payload.power.active = payload.txActive;
|
|
payload.txState = txState || null;
|
|
} catch {
|
|
payload.txState = null;
|
|
}
|
|
|
|
const pttProviderId = runtime.pluginState.providers["microham.ptt"] || null;
|
|
payload.ptt.providerEnabled = pttProviderId ? Boolean(runtime.pluginState.enabled[pttProviderId]) : null;
|
|
let pttStatus = null;
|
|
let pttError = null;
|
|
try {
|
|
pttStatus = await executeCapability("microham.ptt", "pttStatus", {}, { user, skipTxSafety: true });
|
|
} catch (error) {
|
|
pttError = String(error && error.message ? error.message : error);
|
|
const likelyProviderIssue = /No provider for capability microham\.ptt|Provider plugin .* missing|Provider plugin .* disabled/i.test(pttError);
|
|
if (likelyProviderIssue) {
|
|
try {
|
|
await healMicrohamProviders();
|
|
pttStatus = await executeCapability("microham.ptt", "pttStatus", {}, { user, skipTxSafety: true });
|
|
pttError = null;
|
|
} catch (retryError) {
|
|
pttError = String(retryError && retryError.message ? retryError.message : retryError);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (pttStatus) {
|
|
payload.ptt.commandConfigured = Boolean(pttStatus && pttStatus.commandConfigured);
|
|
payload.ptt.active = Boolean(pttStatus && pttStatus.active);
|
|
payload.pttActive = payload.ptt.active;
|
|
} else if (pttError) {
|
|
payload.ptt.error = pttError;
|
|
payload.ptt.commandConfigured = isLikelyPttConfiguredFromEnv();
|
|
console.warn("openwebrx.plugin.state pttStatus failed:", {
|
|
error: payload.ptt.error,
|
|
providerId: payload.ptt.providerId,
|
|
providerEnabled: payload.ptt.providerEnabled,
|
|
userId: user && user.id ? String(user.id) : null
|
|
});
|
|
}
|
|
|
|
const liveState = getOpenWebRxLiveStateForUser(user && user.id ? user.id : "");
|
|
payload.ptt.liveState = liveState;
|
|
payload.ptt.liveStateReady = Boolean(payload.ptt.commandConfigured && liveState && liveState.frequencyHz && liveState.mode);
|
|
|
|
try {
|
|
const audioStatus = await executeCapability("tx.audio", "audioStatus", { userId: user && user.id ? String(user.id) : "" }, { user, skipTxSafety: true });
|
|
if (audioStatus && typeof audioStatus === "object") {
|
|
payload.txAudio = audioStatus;
|
|
}
|
|
} catch (error) {
|
|
if (isLikelyMicrohamProviderIssue(error)) {
|
|
try {
|
|
await healMicrohamProviders();
|
|
const audioStatus = await executeCapability("tx.audio", "audioStatus", { userId: user && user.id ? String(user.id) : "" }, { user, skipTxSafety: true });
|
|
if (audioStatus && typeof audioStatus === "object") {
|
|
payload.txAudio = audioStatus;
|
|
}
|
|
} catch {
|
|
payload.txAudio.lastError = String(error && error.message ? error.message : error);
|
|
}
|
|
} else {
|
|
payload.txAudio.lastError = String(error && error.message ? error.message : error);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const providerId = runtime.pluginState.providers["rfroute.set"];
|
|
if (providerId) {
|
|
const plugin = runtime.plugins.get(providerId);
|
|
if (plugin && plugin.instance && typeof plugin.instance.getStatus === "function") {
|
|
const status = await withTimeout(safeStatus(plugin.instance), 1000, "rfroute status timeout");
|
|
payload.rfroute = {
|
|
current: status && status.current ? String(status.current) : null,
|
|
options: Array.isArray(status && status.options) ? status.options.map((entry) => String(entry)) : []
|
|
};
|
|
}
|
|
}
|
|
} catch {
|
|
payload.rfroute = { current: null, options: [] };
|
|
}
|
|
|
|
const rfRouteAsAntenna = normalizeAntennaRoute(payload.rfroute && payload.rfroute.current ? payload.rfroute.current : "");
|
|
payload.rfPath.current = payload.pttActive ? "tx" : "rx";
|
|
if (rfRouteAsAntenna) {
|
|
payload.antennaRoute = rfRouteAsAntenna;
|
|
payload.antenna.current = rfRouteAsAntenna;
|
|
}
|
|
if (!payload.antennaRoute) {
|
|
payload.antennaRoute = normalizeAntennaRoute(runtime.openWebRxAntennaRoute || "") || null;
|
|
payload.antenna.current = payload.antennaRoute;
|
|
}
|
|
|
|
try {
|
|
const provider = runtime.pluginState.providers["openwebrx.band.read"];
|
|
if (provider) {
|
|
try {
|
|
const stateResult = await withTimeout(
|
|
executeCapability("openwebrx.band.read", "getState", {}, { user, skipTxSafety: true }),
|
|
1000,
|
|
"band state timeout"
|
|
);
|
|
if (stateResult && stateResult.selectedBand) {
|
|
payload.selectedBand = stateResult.selectedBand;
|
|
payload.selectedBandConfigId = stateResult.selectedBand;
|
|
}
|
|
const directRoute = normalizeAntennaRoute(stateResult && stateResult.antennaRoute ? stateResult.antennaRoute : "");
|
|
if (directRoute) {
|
|
payload.antennaRoute = directRoute;
|
|
payload.antenna.current = directRoute;
|
|
}
|
|
} catch {
|
|
// fallback to getBands only
|
|
}
|
|
|
|
const result = await withTimeout(
|
|
executeCapability("openwebrx.band.read", "getBands", {}, { user, skipTxSafety: true }),
|
|
1200,
|
|
"bands read timeout"
|
|
);
|
|
payload.bands = Array.isArray(result && result.bands) ? result.bands : [];
|
|
if (!payload.selectedBand) {
|
|
payload.selectedBand = result && result.selectedBand ? result.selectedBand : null;
|
|
}
|
|
if (!payload.selectedBandConfigId) {
|
|
payload.selectedBandConfigId = payload.selectedBand;
|
|
}
|
|
if (!payload.antennaRoute) {
|
|
const selectedBand = String(payload.selectedBand || "").trim();
|
|
if (selectedBand) {
|
|
const selected = payload.bands.find((entry) => {
|
|
if (!entry || typeof entry !== "object") {
|
|
return false;
|
|
}
|
|
return String(entry.band || entry.id || "").trim() === selectedBand;
|
|
});
|
|
if (selected && typeof selected === "object") {
|
|
payload.antennaRoute = normalizeAntennaRoute(selected.antennaRoute || "") || null;
|
|
payload.antenna.current = payload.antennaRoute;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
payload.bands = [];
|
|
payload.selectedBand = null;
|
|
payload.selectedBandConfigId = null;
|
|
}
|
|
|
|
const selectedBandConfigId = String(payload.selectedBandConfigId || payload.selectedBand || "").trim();
|
|
payload.ptt.blockedByBand = isPttBlockedForBandConfigId(selectedBandConfigId);
|
|
|
|
payload.rotor = normalizeRotorPayload(runtime.rotor);
|
|
|
|
return sendJson(res, 200, payload);
|
|
}
|
|
|
|
function isPowerControlConfigured() {
|
|
const enable = String(process.env.TX_ENABLE_REAL_CMD || process.env.POWER_ON_CMD || "").trim();
|
|
const disable = String(process.env.TX_DISABLE_REAL_CMD || process.env.POWER_OFF_CMD || "").trim();
|
|
return Boolean(enable && disable);
|
|
}
|
|
|
|
function isRfroutePttConfigured() {
|
|
const enabled = openWebRxPttCommandsEnabled();
|
|
const down = String(config.openWebRxPttDownCmd || "").trim();
|
|
const up = String(config.openWebRxPttUpCmd || "").trim();
|
|
return Boolean(enabled && down && up);
|
|
}
|
|
|
|
function isLikelyPttConfiguredFromEnv() {
|
|
const enabled = String(process.env.MICROHAM_PTT_COMMANDS_ENABLED || "false").trim().toLowerCase();
|
|
if (!(enabled === "true" || enabled === "1" || enabled === "yes" || enabled === "on")) {
|
|
return false;
|
|
}
|
|
const down = String(process.env.MICROHAM_PTT_DOWN_CMD || "").trim();
|
|
const up = String(process.env.MICROHAM_PTT_UP_CMD || "").trim();
|
|
return Boolean(down && up);
|
|
}
|
|
|
|
function isLikelyMicrohamProviderIssue(error) {
|
|
const message = String(error && error.message ? error.message : error || "");
|
|
return /No provider for capability (microham\.|tx\.audio)|Provider plugin .* missing|Provider plugin .* disabled/i.test(message);
|
|
}
|
|
|
|
function isLikelyMicrohamAudioEnabledFromEnv() {
|
|
const raw = String(process.env.MICROHAM_AUDIO_ENABLED || "true").trim().toLowerCase();
|
|
if (!raw) {
|
|
return true;
|
|
}
|
|
return raw === "true" || raw === "1" || raw === "yes" || raw === "on";
|
|
}
|
|
|
|
async function healMicrohamProviders() {
|
|
ensureDefaultProviders();
|
|
await savePluginState();
|
|
}
|
|
|
|
function isRfrouteTxRxConfigured() {
|
|
const tx = String(process.env.RFROUTE_CMD_TX || "").trim();
|
|
const rx = String(process.env.RFROUTE_CMD_RX || "").trim();
|
|
return Boolean(tx && rx);
|
|
}
|
|
|
|
function isAntennaRoutingConfigured() {
|
|
const draht = String(process.env.RFROUTE_CMD_DRAHT || "").trim();
|
|
const beam = String(process.env.RFROUTE_CMD_BEAM || "").trim();
|
|
const wrtc = String(process.env.RFROUTE_CMD_WRTC || "").trim();
|
|
return Boolean(draht && beam && wrtc);
|
|
}
|
|
|
|
function withTimeout(promise, timeoutMs, message) {
|
|
const ms = Number.isFinite(Number(timeoutMs)) ? Math.max(50, Number(timeoutMs)) : 1000;
|
|
let timer = null;
|
|
return Promise.race([
|
|
Promise.resolve(promise),
|
|
new Promise((_, reject) => {
|
|
timer = setTimeout(() => {
|
|
reject(new Error(String(message || "timeout")));
|
|
}, ms);
|
|
if (timer && typeof timer.unref === "function") {
|
|
timer.unref();
|
|
}
|
|
})
|
|
]).finally(() => {
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function handleOpenWebRxPluginTx(req, res, url, enable) {
|
|
const auth = await requireOpenWebRxTicketUser(req, res, url);
|
|
if (!auth) {
|
|
return;
|
|
}
|
|
const user = auth.user;
|
|
return enable ? handleOpenWebRxTxEnable(res, user) : handleOpenWebRxTxDisable(res, user);
|
|
}
|
|
|
|
async function handleOpenWebRxPluginPtt(req, res, url, down) {
|
|
const auth = await requireOpenWebRxTicketUser(req, res, url);
|
|
if (!auth) {
|
|
return;
|
|
}
|
|
const body = down ? await readJsonBody(req) : {};
|
|
return down ? handleOpenWebRxPttDown(res, auth.user, body || {}) : handleOpenWebRxPttUp(res, auth.user);
|
|
}
|
|
|
|
async function handleOpenWebRxPluginLiveState(req, res, url, body) {
|
|
const auth = await requireOpenWebRxTicketUser(req, res, url);
|
|
if (!auth) {
|
|
return;
|
|
}
|
|
const user = auth.user;
|
|
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
|
|
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX Live-State nur fuer aktiven Stationsbenutzer");
|
|
}
|
|
const parsed = parseOpenWebRxLiveState(body || {});
|
|
if (!parsed.frequencyHz || !parsed.mode) {
|
|
return sendError(res, 409, "openwebrx.live_state_invalid", "Live-Frequenz oder Mode fehlt");
|
|
}
|
|
setOpenWebRxLiveStateForUser(user.id, {
|
|
frequencyHz: parsed.frequencyHz,
|
|
mode: parsed.mode,
|
|
updatedAtMs: Date.now(),
|
|
source: "plugin"
|
|
});
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
liveState: {
|
|
frequencyHz: parsed.frequencyHz,
|
|
mode: parsed.mode
|
|
}
|
|
});
|
|
}
|
|
|
|
async function handleOpenWebRxPluginAudioStatus(req, res, url) {
|
|
const auth = await requireOpenWebRxTicketUser(req, res, url);
|
|
if (!auth) {
|
|
return;
|
|
}
|
|
try {
|
|
const audio = await executeCapability("tx.audio", "audioStatus", { userId: String(auth.user.id || "") }, { user: auth.user, skipTxSafety: true });
|
|
return sendJson(res, 200, { ok: true, audio });
|
|
} catch (error) {
|
|
if (isLikelyMicrohamProviderIssue(error)) {
|
|
await healMicrohamProviders();
|
|
try {
|
|
const audio = await executeCapability("tx.audio", "audioStatus", { userId: String(auth.user.id || "") }, { user: auth.user, skipTxSafety: true });
|
|
return sendJson(res, 200, { ok: true, audio });
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
return sendError(res, 409, "tx.audio.status_failed", String(error && error.message ? error.message : error));
|
|
}
|
|
}
|
|
|
|
async function handleOpenWebRxPluginAudioConnect(req, res, url) {
|
|
const auth = await requireOpenWebRxTicketUser(req, res, url);
|
|
if (!auth) {
|
|
return;
|
|
}
|
|
try {
|
|
await executeCapability("tx.audio", "audioConnect", {
|
|
userId: String(auth.user.id || ""),
|
|
reason: "plugin-connect"
|
|
}, { user: auth.user, skipTxSafety: true });
|
|
const audio = await executeCapability("tx.audio", "audioStatus", { userId: String(auth.user.id || "") }, { user: auth.user, skipTxSafety: true });
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
audio
|
|
});
|
|
} catch (error) {
|
|
if (isLikelyMicrohamProviderIssue(error)) {
|
|
await healMicrohamProviders();
|
|
try {
|
|
await executeCapability("tx.audio", "audioConnect", {
|
|
userId: String(auth.user.id || ""),
|
|
reason: "plugin-connect"
|
|
}, { user: auth.user, skipTxSafety: true });
|
|
const audio = await executeCapability("tx.audio", "audioStatus", { userId: String(auth.user.id || "") }, { user: auth.user, skipTxSafety: true });
|
|
return sendJson(res, 200, { ok: true, audio });
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
return sendError(res, 409, "tx.audio.start_failed", String(error && error.message ? error.message : error));
|
|
}
|
|
}
|
|
|
|
async function handleOpenWebRxPluginAudioDisconnect(req, res, url) {
|
|
const auth = await requireOpenWebRxTicketUser(req, res, url);
|
|
if (!auth) {
|
|
return;
|
|
}
|
|
try {
|
|
await executeCapability("tx.audio", "audioDisconnect", {
|
|
userId: String(auth.user.id || ""),
|
|
reason: "plugin-disconnect"
|
|
}, { user: auth.user, skipTxSafety: true });
|
|
} catch {
|
|
// best effort
|
|
}
|
|
let audio = null;
|
|
try {
|
|
audio = await executeCapability("tx.audio", "audioStatus", { userId: String(auth.user.id || "") }, { user: auth.user, skipTxSafety: true });
|
|
} catch {
|
|
audio = null;
|
|
}
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
audio
|
|
});
|
|
}
|
|
|
|
async function handleOpenWebRxPluginBandSelect(req, res, url, body) {
|
|
const auth = await requireOpenWebRxTicketUser(req, res, url);
|
|
if (!auth) {
|
|
return;
|
|
}
|
|
return handleOpenWebRxBandSelect(res, auth.user, body);
|
|
}
|
|
|
|
async function handleOpenWebRxPluginRotorSet(req, res, url, body) {
|
|
const auth = await requireOpenWebRxTicketUser(req, res, url);
|
|
if (!auth) {
|
|
return;
|
|
}
|
|
return handleOpenWebRxRotorSet(res, auth.user, body, "owrx-plugin");
|
|
}
|
|
|
|
function txAudioEnabled() {
|
|
return String(config.txAudioEnabled || "true").trim().toLowerCase() !== "false";
|
|
}
|
|
|
|
function txAudioStopOnDisconnect() {
|
|
return String(config.txAudioStopOnDisconnect || "true").trim().toLowerCase() !== "false";
|
|
}
|
|
|
|
function txAudioInputFormatArg() {
|
|
const value = String(config.txAudioInputMime || "webm").trim().toLowerCase();
|
|
if (value === "ogg") {
|
|
return "ogg";
|
|
}
|
|
return "webm";
|
|
}
|
|
|
|
function txAudioChunkMs() {
|
|
const value = Number(config.txAudioChunkMs);
|
|
if (!Number.isFinite(value)) {
|
|
return 100;
|
|
}
|
|
return Math.max(40, Math.min(2000, Math.floor(value)));
|
|
}
|
|
|
|
function buildTxAudioStatusPayload(user) {
|
|
const owner = runtime.txAudio.ownerUserId || null;
|
|
const currentUserId = user && user.id ? String(user.id) : null;
|
|
const enabled = txAudioEnabled();
|
|
let state = "disconnected";
|
|
if (!enabled) {
|
|
state = "disabled";
|
|
} else if (runtime.txAudio.running) {
|
|
state = "running";
|
|
} else if (runtime.txAudio.lastError) {
|
|
state = "error";
|
|
}
|
|
return {
|
|
enabled,
|
|
state,
|
|
running: Boolean(runtime.txAudio.running),
|
|
clients: runtime.txAudio.clients ? runtime.txAudio.clients.size : 0,
|
|
ownerUserId: owner,
|
|
ownerMatchesCaller: Boolean(owner && currentUserId && owner === currentUserId),
|
|
startedAt: runtime.txAudio.startedAt || null,
|
|
lastError: runtime.txAudio.lastError || null,
|
|
lastExit: runtime.txAudio.lastExit || null,
|
|
ffmpegPath: resolveTxAudioFfmpegPath() || null,
|
|
alsaDevice: runtime.txAudio.alsaDevice || (String(config.txAudioAlsaDevice || "plughw:CARD=CODEC,DEV=0").trim() || "plughw:CARD=CODEC,DEV=0"),
|
|
chunkMs: txAudioChunkMs(),
|
|
wsPath: "/v1/openwebrx/plugin/audio/ws"
|
|
};
|
|
}
|
|
|
|
function clearTxAudioIdleTimer() {
|
|
if (!runtime.txAudio.idleTimer) {
|
|
return;
|
|
}
|
|
clearTimeout(runtime.txAudio.idleTimer);
|
|
runtime.txAudio.idleTimer = null;
|
|
}
|
|
|
|
function scheduleTxAudioIdleStop() {
|
|
clearTxAudioIdleTimer();
|
|
const timeoutMs = Number.isFinite(Number(config.txAudioSessionTimeoutMs))
|
|
? Math.max(1000, Math.floor(Number(config.txAudioSessionTimeoutMs)))
|
|
: 120000;
|
|
runtime.txAudio.idleTimer = setTimeout(() => {
|
|
stopTxAudioBridge("idle-timeout", null).catch(() => {});
|
|
}, timeoutMs);
|
|
if (runtime.txAudio.idleTimer && typeof runtime.txAudio.idleTimer.unref === "function") {
|
|
runtime.txAudio.idleTimer.unref();
|
|
}
|
|
}
|
|
|
|
function spawnTxAudioFfmpeg(alsaDeviceOverride = null) {
|
|
const ffmpegPath = resolveTxAudioFfmpegPath();
|
|
if (!ffmpegPath) {
|
|
throw new Error("ffmpeg binary not found (set TX_AUDIO_FFMPEG_PATH)");
|
|
}
|
|
const alsaDevice = String(alsaDeviceOverride || config.txAudioAlsaDevice || "plughw:CARD=CODEC,DEV=0").trim() || "plughw:CARD=CODEC,DEV=0";
|
|
const args = [
|
|
"-hide_banner",
|
|
"-loglevel", "warning",
|
|
"-fflags", "+nobuffer",
|
|
"-flags", "low_delay",
|
|
"-thread_queue_size", "1024",
|
|
"-f", txAudioInputFormatArg(),
|
|
"-i", "pipe:0",
|
|
"-ac", "2",
|
|
"-f", "alsa",
|
|
alsaDevice
|
|
];
|
|
const extra = splitCommand(String(config.txAudioFfmpegExtraArgs || ""));
|
|
if (extra.length > 0) {
|
|
args.splice(args.length - 2, 0, ...extra);
|
|
}
|
|
const proc = spawn(ffmpegPath, args, {
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
cwd: process.cwd(),
|
|
env: process.env
|
|
});
|
|
return proc;
|
|
}
|
|
|
|
function txAudioAlsaCandidates() {
|
|
const configured = String(config.txAudioAlsaDevice || "plughw:CARD=CODEC,DEV=0").trim() || "plughw:CARD=CODEC,DEV=0";
|
|
const candidates = [configured, "default", "plughw:0,0"];
|
|
return [...new Set(candidates.map((entry) => String(entry || "").trim()).filter(Boolean))];
|
|
}
|
|
|
|
function resolveTxAudioFfmpegPath() {
|
|
const configured = String(config.txAudioFfmpegPath || "").trim();
|
|
const linuxCandidates = [
|
|
"/usr/bin/ffmpeg",
|
|
"/usr/local/bin/ffmpeg",
|
|
"/bin/ffmpeg"
|
|
];
|
|
if (configured) {
|
|
if (configured.includes(path.sep) || configured.includes("/")) {
|
|
if (fs.existsSync(configured)) {
|
|
return configured;
|
|
}
|
|
const fallbackName = path.basename(configured) || "ffmpeg";
|
|
for (const candidate of linuxCandidates) {
|
|
if (fs.existsSync(candidate) && path.basename(candidate) === fallbackName) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return fallbackName;
|
|
}
|
|
if (process.platform === "linux") {
|
|
for (const candidate of linuxCandidates) {
|
|
if (fs.existsSync(candidate) && path.basename(candidate) === configured) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
return configured;
|
|
}
|
|
if (process.platform === "linux") {
|
|
for (const candidate of linuxCandidates) {
|
|
if (fs.existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
return "ffmpeg";
|
|
}
|
|
|
|
async function ensureTxAudioBridgeForOwner(user, reason) {
|
|
if (!txAudioEnabled()) {
|
|
return;
|
|
}
|
|
if (!user || !user.id) {
|
|
throw new Error("no user context for tx audio");
|
|
}
|
|
const ownerUserId = String(user.id);
|
|
if (runtime.txAudio.running) {
|
|
if (runtime.txAudio.ownerUserId && runtime.txAudio.ownerUserId !== ownerUserId) {
|
|
const hasClients = runtime.txAudio.clients && runtime.txAudio.clients.size > 0;
|
|
if (!hasClients) {
|
|
await stopTxAudioBridge("owner-handover", null).catch(() => {});
|
|
await waitMs(100);
|
|
}
|
|
}
|
|
if (runtime.txAudio.running && runtime.txAudio.ownerUserId && runtime.txAudio.ownerUserId !== ownerUserId) {
|
|
throw new Error("TX Audio wird bereits von einem anderen Benutzer verwendet");
|
|
}
|
|
runtime.txAudio.ownerUserId = ownerUserId;
|
|
clearTxAudioIdleTimer();
|
|
return;
|
|
}
|
|
|
|
clearTxAudioIdleTimer();
|
|
const startupErrors = [];
|
|
for (const candidateDevice of txAudioAlsaCandidates()) {
|
|
runtime.txAudio.lastError = null;
|
|
let proc;
|
|
try {
|
|
proc = spawnTxAudioFfmpeg(candidateDevice);
|
|
} catch (error) {
|
|
startupErrors.push(`${candidateDevice}: ${String(error && error.message ? error.message : error)}`);
|
|
continue;
|
|
}
|
|
runtime.txAudio.ffmpeg = proc;
|
|
runtime.txAudio.running = true;
|
|
runtime.txAudio.startedAt = new Date().toISOString();
|
|
runtime.txAudio.ownerUserId = ownerUserId;
|
|
runtime.txAudio.alsaDevice = candidateDevice;
|
|
runtime.txAudio.stopRequested = false;
|
|
|
|
let stderrBuffer = "";
|
|
if (proc.stderr) {
|
|
proc.stderr.on("data", (chunk) => {
|
|
const text = String(chunk || "");
|
|
stderrBuffer = `${stderrBuffer}${text}`.slice(-4000);
|
|
if (!runtime.txAudio.stopRequested && text.trim()) {
|
|
runtime.txAudio.lastError = text.trim();
|
|
}
|
|
});
|
|
}
|
|
|
|
proc.on("error", (error) => {
|
|
if (!runtime.txAudio.stopRequested) {
|
|
runtime.txAudio.lastError = String(error && error.message ? error.message : error);
|
|
}
|
|
});
|
|
|
|
proc.on("close", (code, signal) => {
|
|
runtime.txAudio.lastExit = {
|
|
at: new Date().toISOString(),
|
|
code: Number.isFinite(Number(code)) ? Number(code) : null,
|
|
signal: signal || null,
|
|
stderr: stderrBuffer || null
|
|
};
|
|
runtime.txAudio.running = false;
|
|
runtime.txAudio.ffmpeg = null;
|
|
runtime.txAudio.startedAt = null;
|
|
runtime.txAudio.ownerUserId = null;
|
|
runtime.txAudio.alsaDevice = null;
|
|
runtime.txAudio.stopRequested = false;
|
|
clearTxAudioIdleTimer();
|
|
for (const ws of runtime.txAudio.clients) {
|
|
try {
|
|
ws.close(1011, "audio backend closed");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
runtime.txAudio.clients.clear();
|
|
});
|
|
|
|
await waitMs(180);
|
|
if (runtime.txAudio.running) {
|
|
await appendAudit("openwebrx.audio.start", user, { reason, alsaDevice: candidateDevice });
|
|
return;
|
|
}
|
|
|
|
startupErrors.push(`${candidateDevice}: ${runtime.txAudio.lastError || "start failed"}`);
|
|
}
|
|
|
|
runtime.txAudio.alsaDevice = null;
|
|
throw new Error(startupErrors.length > 0 ? startupErrors.join(" | ") : "TX Audio Backend konnte nicht gestartet werden");
|
|
}
|
|
|
|
async function stopTxAudioBridge(reason, user) {
|
|
clearTxAudioIdleTimer();
|
|
const proc = runtime.txAudio.ffmpeg;
|
|
runtime.txAudio.stopRequested = true;
|
|
if (!proc || !runtime.txAudio.running) {
|
|
runtime.txAudio.running = false;
|
|
runtime.txAudio.ffmpeg = null;
|
|
runtime.txAudio.ownerUserId = null;
|
|
runtime.txAudio.alsaDevice = null;
|
|
runtime.txAudio.stopRequested = false;
|
|
runtime.txAudio.lastError = null;
|
|
for (const ws of runtime.txAudio.clients) {
|
|
try {
|
|
ws.close(1000, "audio disconnected");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
runtime.txAudio.clients.clear();
|
|
return;
|
|
}
|
|
|
|
for (const ws of runtime.txAudio.clients) {
|
|
try {
|
|
ws.close(1000, "audio disconnected");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
runtime.txAudio.clients.clear();
|
|
|
|
try {
|
|
if (proc.stdin && !proc.stdin.destroyed) {
|
|
proc.stdin.end();
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
await waitMs(150);
|
|
if (runtime.txAudio.running && !proc.killed) {
|
|
try {
|
|
proc.kill("SIGTERM");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (user) {
|
|
await appendAudit("openwebrx.audio.stop", user, { reason, alsaDevice: runtime.txAudio.alsaDevice || null }).catch(() => {});
|
|
}
|
|
runtime.txAudio.lastError = null;
|
|
}
|
|
|
|
function initTxAudioWebSocket(server) {
|
|
const wss = new WebSocketServer({ noServer: true });
|
|
runtime.txAudio.wsServer = wss;
|
|
|
|
server.on("upgrade", (req, socket, head) => {
|
|
let url;
|
|
try {
|
|
url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
} catch {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
if (url.pathname !== "/v1/openwebrx/plugin/audio/ws") {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
resolveOpenWebRxTicketAccess(req, url).then((access) => {
|
|
if (!access || !access.ok || !access.user) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
if (denyByStationOwnerForUser(access.user)) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
ws._rmsUser = access.user;
|
|
wss.emit("connection", ws, req);
|
|
});
|
|
}).catch(() => {
|
|
socket.destroy();
|
|
});
|
|
});
|
|
|
|
wss.on("connection", async (ws) => {
|
|
const user = ws._rmsUser;
|
|
const userId = String(user && user.id ? user.id : "");
|
|
try {
|
|
await executeCapability("tx.audio", "audioRegisterClient", {
|
|
ws,
|
|
userId,
|
|
reason: "ws-connect"
|
|
}, { user, skipTxSafety: true });
|
|
} catch (error) {
|
|
try {
|
|
ws.close(1011, String(error && error.message ? error.message : error));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return;
|
|
}
|
|
|
|
ws.on("message", async (message, isBinary) => {
|
|
if (!isBinary) {
|
|
return;
|
|
}
|
|
try {
|
|
await executeCapability("tx.audio", "audioWriteChunk", {
|
|
ws,
|
|
userId,
|
|
chunk: Buffer.isBuffer(message) ? message : Buffer.from(message)
|
|
}, { user, skipTxSafety: true });
|
|
} catch {
|
|
// ignore chunk failure
|
|
}
|
|
});
|
|
|
|
ws.on("close", () => {
|
|
executeCapability("tx.audio", "audioUnregisterClient", {
|
|
ws,
|
|
userId,
|
|
reason: "ws-disconnect"
|
|
}, { user, skipTxSafety: true }).catch(() => {});
|
|
});
|
|
|
|
ws.on("error", () => {
|
|
executeCapability("tx.audio", "audioUnregisterClient", {
|
|
ws,
|
|
userId,
|
|
reason: "ws-error"
|
|
}, { user, skipTxSafety: true }).catch(() => {});
|
|
});
|
|
});
|
|
}
|
|
|
|
function denyByStationOwnerForUser(user) {
|
|
if (!runtime.station || !runtime.station.isInUse || !runtime.station.activeByUserId) {
|
|
return false;
|
|
}
|
|
return String(user && user.id ? user.id : "") !== String(runtime.station.activeByUserId || "");
|
|
}
|
|
|
|
async function syncStationAccessPolicyOwner(user, ownerEmail) {
|
|
const provider = runtime.pluginState.providers["admin.station.access.policy.write"];
|
|
if (!provider) {
|
|
return;
|
|
}
|
|
try {
|
|
await executeCapability("admin.station.access.policy.write", "syncOwner", { ownerEmail }, { user, skipTxSafety: true });
|
|
} catch {
|
|
// optional capability
|
|
}
|
|
}
|
|
|
|
async function clearStationAccessPolicyOwner(user) {
|
|
const provider = runtime.pluginState.providers["admin.station.access.policy.write"];
|
|
if (!provider) {
|
|
return;
|
|
}
|
|
try {
|
|
await executeCapability("admin.station.access.policy.write", "clearOwner", {}, { user, skipTxSafety: true });
|
|
} catch {
|
|
// optional capability
|
|
}
|
|
}
|
|
|
|
async function requireOpenWebRxTicketUser(req, res, url) {
|
|
try {
|
|
const result = await resolveOpenWebRxTicketAccess(req, url);
|
|
if (!result || !result.ok || !result.user) {
|
|
res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
|
|
res.end(JSON.stringify({ ok: false, error: "openwebrx.forbidden", message: "OpenWebRX Zugriff verweigert" }));
|
|
return null;
|
|
}
|
|
return { user: result.user };
|
|
} catch {
|
|
res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
|
|
res.end(JSON.stringify({ ok: false, error: "openwebrx.forbidden", message: "OpenWebRX Zugriff verweigert" }));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function resolveOpenWebRxTicketAccess(req, url) {
|
|
if (canAuthorizeOpenWebRxByActiveSession()) {
|
|
const activeUser = runtime.users.find((entry) => String(entry.id) === String(runtime.station.activeByUserId || ""));
|
|
if (activeUser) {
|
|
return { ok: true, user: activeUser };
|
|
}
|
|
}
|
|
|
|
const queryTicket = String(url && url.searchParams ? (url.searchParams.get("ticket") || "") : "").trim();
|
|
const cookieTicket = readCookie(req.headers && req.headers.cookie, "rms_owrx_ticket");
|
|
const originalUriHeader = req.headers ? (req.headers["x-original-uri"] || req.headers["x-rewrite-uri"] || "") : "";
|
|
const originalUriTicket = extractTicketFromUri(originalUriHeader);
|
|
const refererHeader = req.headers ? (req.headers.referer || req.headers.referrer || "") : "";
|
|
const refererTicket = extractTicketFromUri(refererHeader);
|
|
|
|
const tickets = [];
|
|
if (queryTicket) tickets.push(queryTicket);
|
|
if (cookieTicket && cookieTicket !== queryTicket) tickets.push(cookieTicket);
|
|
if (originalUriTicket && !tickets.includes(originalUriTicket)) tickets.push(originalUriTicket);
|
|
if (refererTicket && !tickets.includes(refererTicket)) tickets.push(refererTicket);
|
|
if (tickets.length === 0) {
|
|
const fallbackUser = resolveOpenWebRxOwnerFromRequestContext(req, url);
|
|
if (fallbackUser) {
|
|
return { ok: true, user: fallbackUser };
|
|
}
|
|
return { ok: false };
|
|
}
|
|
|
|
const provider = runtime.pluginState.providers["openwebrx.access.verify"];
|
|
if (!provider) {
|
|
return { ok: false };
|
|
}
|
|
|
|
let verified = null;
|
|
for (const ticket of tickets) {
|
|
const candidate = await executeCapability("openwebrx.access.verify", "verifyAccess", { ticket }, { skipTxSafety: true });
|
|
if (candidate && candidate.ok) {
|
|
verified = candidate;
|
|
break;
|
|
}
|
|
}
|
|
const ownerUserId = runtime.station.activeByUserId;
|
|
const allowed = Boolean(
|
|
verified &&
|
|
verified.ok &&
|
|
runtime.station.isInUse &&
|
|
ownerUserId &&
|
|
String(verified.userId) === String(ownerUserId)
|
|
);
|
|
if (!allowed) {
|
|
const fallbackUser = resolveOpenWebRxOwnerFromRequestContext(req, url);
|
|
if (fallbackUser) {
|
|
return { ok: true, user: fallbackUser };
|
|
}
|
|
return { ok: false };
|
|
}
|
|
|
|
let user = runtime.users.find((entry) => String(entry.id) === String(verified.userId));
|
|
if (!user) {
|
|
user = {
|
|
id: String(verified.userId),
|
|
email: String(runtime.station.activeByEmail || "openwebrx-owner"),
|
|
role: "operator"
|
|
};
|
|
}
|
|
|
|
markOpenWebRxSession(user, verified);
|
|
return { ok: true, user };
|
|
}
|
|
|
|
function resolveOpenWebRxOwnerFromRequestContext(req, url) {
|
|
if (!runtime.station || !runtime.station.isInUse || !runtime.station.activeByUserId) {
|
|
return null;
|
|
}
|
|
const openWebRxPath = String(config.openWebRxPath || "/sdr/").trim() || "/sdr/";
|
|
const pathname = String(url && url.pathname ? url.pathname : "");
|
|
const originalUri = String(req && req.headers ? (req.headers["x-original-uri"] || req.headers["x-rewrite-uri"] || "") : "");
|
|
const referer = String(req && req.headers ? (req.headers.referer || req.headers.referrer || "") : "");
|
|
|
|
const looksLikeOpenWebRxContext = pathname.startsWith("/v1/openwebrx/plugin/")
|
|
|| originalUri.includes(openWebRxPath)
|
|
|| referer.includes(openWebRxPath);
|
|
if (!looksLikeOpenWebRxContext) {
|
|
return null;
|
|
}
|
|
|
|
return runtime.users.find((entry) => String(entry.id) === String(runtime.station.activeByUserId || "")) || null;
|
|
}
|
|
|
|
async function handleOpenWebRxAuthorize(req, res, url) {
|
|
if (canAuthorizeOpenWebRxByActiveSession()) {
|
|
await ensureOpenWebRxSdrPath(null, { force: false, minIntervalMs: 3000 });
|
|
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
res.end("ok");
|
|
return;
|
|
}
|
|
|
|
const queryTicket = String(url.searchParams.get("ticket") || "").trim();
|
|
const cookieTicket = readCookie(req.headers && req.headers.cookie, "rms_owrx_ticket");
|
|
const originalUriHeader = req.headers ? (req.headers["x-original-uri"] || req.headers["x-rewrite-uri"] || "") : "";
|
|
const originalUriTicket = extractTicketFromUri(originalUriHeader);
|
|
const refererHeader = req.headers ? (req.headers.referer || req.headers.referrer || "") : "";
|
|
const refererTicket = extractTicketFromUri(refererHeader);
|
|
const tickets = [];
|
|
if (queryTicket) {
|
|
tickets.push(queryTicket);
|
|
}
|
|
if (cookieTicket && cookieTicket !== queryTicket) {
|
|
tickets.push(cookieTicket);
|
|
}
|
|
if (originalUriTicket && !tickets.includes(originalUriTicket)) {
|
|
tickets.push(originalUriTicket);
|
|
}
|
|
if (refererTicket && !tickets.includes(refererTicket)) {
|
|
tickets.push(refererTicket);
|
|
}
|
|
if (tickets.length === 0) {
|
|
if (canAuthorizeOpenWebRxWebSocketWithoutTicket(req, originalUriHeader)) {
|
|
await ensureOpenWebRxSdrPath(null, { force: false, minIntervalMs: 3000 });
|
|
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
res.end("ok");
|
|
return;
|
|
}
|
|
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
|
res.end("forbidden");
|
|
return;
|
|
}
|
|
|
|
const provider = runtime.pluginState.providers["openwebrx.access.verify"];
|
|
if (!provider) {
|
|
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
|
res.end("forbidden");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let result = null;
|
|
for (const ticket of tickets) {
|
|
const candidate = await executeCapability("openwebrx.access.verify", "verifyAccess", { ticket }, { skipTxSafety: true });
|
|
if (candidate && candidate.ok) {
|
|
result = candidate;
|
|
break;
|
|
}
|
|
}
|
|
const ownerUserId = runtime.station.activeByUserId;
|
|
const allowed = Boolean(
|
|
result &&
|
|
result.ok &&
|
|
runtime.station.isInUse &&
|
|
ownerUserId &&
|
|
result.userId === ownerUserId
|
|
);
|
|
if (!allowed) {
|
|
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
|
res.end("forbidden");
|
|
return;
|
|
}
|
|
markOpenWebRxSession({ id: result.userId }, result);
|
|
await ensureOpenWebRxSdrPath(null, { force: false, minIntervalMs: 3000 });
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/plain; charset=utf-8",
|
|
"X-RMS-User-Id": String(result.userId)
|
|
});
|
|
res.end("ok");
|
|
} catch {
|
|
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
|
res.end("forbidden");
|
|
}
|
|
}
|
|
|
|
function readCookie(cookieHeader, name) {
|
|
const target = String(name || "").trim();
|
|
if (!target) {
|
|
return "";
|
|
}
|
|
const raw = String(cookieHeader || "");
|
|
if (!raw) {
|
|
return "";
|
|
}
|
|
const pairs = raw.split(";");
|
|
let lastValue = "";
|
|
for (const pair of pairs) {
|
|
const idx = pair.indexOf("=");
|
|
if (idx === -1) {
|
|
continue;
|
|
}
|
|
const key = pair.slice(0, idx).trim();
|
|
if (key !== target) {
|
|
continue;
|
|
}
|
|
const value = pair.slice(idx + 1).trim();
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
try {
|
|
lastValue = decodeURIComponent(value);
|
|
} catch {
|
|
lastValue = value;
|
|
}
|
|
}
|
|
return lastValue;
|
|
}
|
|
|
|
function extractTicketFromUri(rawUri) {
|
|
const input = String(rawUri || "").trim();
|
|
if (!input) {
|
|
return "";
|
|
}
|
|
const queryIndex = input.indexOf("?");
|
|
if (queryIndex === -1) {
|
|
return "";
|
|
}
|
|
const query = input.slice(queryIndex + 1);
|
|
const params = new URLSearchParams(query);
|
|
return String(params.get("ticket") || "").trim();
|
|
}
|
|
|
|
function canAuthorizeOpenWebRxWebSocketWithoutTicket(req, originalUriHeader) {
|
|
const headers = req && req.headers ? req.headers : {};
|
|
const upgrade = String(headers.upgrade || "").toLowerCase();
|
|
const hasWebSocketKey = Boolean(headers["sec-websocket-key"]);
|
|
const isWebSocket = upgrade === "websocket" || hasWebSocketKey;
|
|
if (!isWebSocket) {
|
|
return false;
|
|
}
|
|
if (!runtime.station || !runtime.station.isInUse || !runtime.station.activeByUserId) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function canAuthorizeOpenWebRxByActiveSession() {
|
|
if (!runtime.station || !runtime.station.isInUse || !runtime.station.activeByUserId) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function handleHelpContent(res, user) {
|
|
if (!hasRole(user, ["operator", "approver", "admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
|
|
}
|
|
|
|
const provider = runtime.pluginState.providers["help.content.read"];
|
|
if (!provider) {
|
|
return sendJson(res, 200, {
|
|
content: {
|
|
version: 1,
|
|
title: "RMS Hilfe",
|
|
quickStart: {
|
|
title: "Schnellstart",
|
|
steps: ["Plugin 'rms.help.basic' aktivieren, um Hilfetexte anzuzeigen."]
|
|
},
|
|
sections: []
|
|
}
|
|
});
|
|
}
|
|
|
|
const content = await executeCapability("help.content.read", "getContent", {}, { user, skipTxSafety: true });
|
|
return sendJson(res, 200, { content });
|
|
}
|
|
|
|
async function handleDebugCollect(req, res, url, scope = "owrx") {
|
|
const auth = authorizeDebugRequest(req, res, url);
|
|
if (!auth) {
|
|
return;
|
|
}
|
|
const settings = getDebugRemoteSettings();
|
|
if (!settings.enabled) {
|
|
return sendError(res, 404, "debug.disabled", "Debug endpoint deaktiviert");
|
|
}
|
|
const requestedLines = Number(url.searchParams.get("lines") || "");
|
|
const lineOverride = Number.isFinite(requestedLines) ? Math.max(50, Math.min(4000, Math.trunc(requestedLines))) : null;
|
|
const result = await collectDebugLogs(scope, settings, lineOverride);
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
scope,
|
|
collectedAt: result.collectedAt,
|
|
totalLines: result.totalLines,
|
|
keptLines: result.keptLines,
|
|
outputPath: result.outputPath,
|
|
snapshotPath: result.snapshotPath
|
|
});
|
|
}
|
|
|
|
async function handleDebugLogs(req, res, url, scope = "owrx") {
|
|
const auth = authorizeDebugRequest(req, res, url);
|
|
if (!auth) {
|
|
return;
|
|
}
|
|
const settings = getDebugRemoteSettings();
|
|
if (!settings.enabled) {
|
|
return sendError(res, 404, "debug.disabled", "Debug endpoint deaktiviert");
|
|
}
|
|
const paths = resolveDebugPaths(scope);
|
|
let raw = "";
|
|
try {
|
|
raw = await fsp.readFile(paths.logFilePath, "utf8");
|
|
} catch {
|
|
raw = "";
|
|
}
|
|
const requestedLines = Number(url.searchParams.get("lines") || settings.collectLines);
|
|
const lineLimit = Math.max(50, Math.min(4000, Number.isFinite(requestedLines) ? Math.trunc(requestedLines) : settings.collectLines));
|
|
const lines = raw.split(/\r?\n/).filter(Boolean);
|
|
const tail = lines.slice(Math.max(0, lines.length - lineLimit));
|
|
const snapshot = await readDebugSnapshot(scope);
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
scope,
|
|
lines: tail,
|
|
total: lines.length,
|
|
limit: lineLimit,
|
|
collectedAt: snapshot && snapshot.collectedAt ? snapshot.collectedAt : null
|
|
});
|
|
}
|
|
|
|
async function handleDebugSnapshot(req, res, url, scope = "owrx") {
|
|
const auth = authorizeDebugRequest(req, res, url);
|
|
if (!auth) {
|
|
return;
|
|
}
|
|
const settings = getDebugRemoteSettings();
|
|
if (!settings.enabled) {
|
|
return sendError(res, 404, "debug.disabled", "Debug endpoint deaktiviert");
|
|
}
|
|
let snapshot = buildDebugRuntimeSnapshot(scope);
|
|
const saved = await readDebugSnapshot(scope);
|
|
if (saved && typeof saved === "object") {
|
|
snapshot = {
|
|
...snapshot,
|
|
...saved,
|
|
ok: true
|
|
};
|
|
}
|
|
return sendJson(res, 200, snapshot);
|
|
}
|
|
|
|
async function handleDebugClear(req, res, url, scope = "owrx") {
|
|
const auth = authorizeDebugRequest(req, res, url);
|
|
if (!auth) {
|
|
return;
|
|
}
|
|
const settings = getDebugRemoteSettings();
|
|
if (!settings.enabled) {
|
|
return sendError(res, 404, "debug.disabled", "Debug endpoint deaktiviert");
|
|
}
|
|
const paths = resolveDebugPaths(scope);
|
|
await fsp.mkdir(paths.debugDir, { recursive: true });
|
|
await fsp.writeFile(paths.logFilePath, "", "utf8");
|
|
await fsp.writeFile(paths.snapshotFilePath, JSON.stringify({ scope, collectedAt: new Date().toISOString(), cleared: true }, null, 2), "utf8");
|
|
return sendJson(res, 200, { ok: true, scope, cleared: true });
|
|
}
|
|
|
|
async function handleDebugWhich(req, res, url) {
|
|
const auth = authorizeDebugRequest(req, res, url);
|
|
if (!auth) {
|
|
return;
|
|
}
|
|
const settings = getDebugRemoteSettings();
|
|
if (!settings.enabled) {
|
|
return sendError(res, 404, "debug.disabled", "Debug endpoint deaktiviert");
|
|
}
|
|
const name = String(url.searchParams.get("bin") || url.searchParams.get("name") || "ffmpeg").trim().toLowerCase();
|
|
if (!/^[a-z0-9._+-]{1,64}$/.test(name)) {
|
|
return sendError(res, 400, "debug.which.invalid", "ungueltiger binary name");
|
|
}
|
|
const found = await resolveBinaryPath(name);
|
|
const collectedAt = new Date().toISOString();
|
|
const paths = resolveDebugPaths("which");
|
|
await fsp.mkdir(paths.debugDir, { recursive: true });
|
|
const line = `${collectedAt} ${name} => ${found.path || "NOT_FOUND"}`;
|
|
await fsp.appendFile(paths.logFilePath, `${line}\n`, "utf8");
|
|
const snapshot = {
|
|
ok: true,
|
|
scope: "which",
|
|
collectedAt,
|
|
name,
|
|
found: found.found,
|
|
path: found.path || null
|
|
};
|
|
await fsp.writeFile(paths.snapshotFilePath, JSON.stringify(snapshot, null, 2), "utf8");
|
|
return sendJson(res, 200, snapshot);
|
|
}
|
|
|
|
async function resolveBinaryPath(name) {
|
|
if (process.platform === "win32") {
|
|
const whereResult = await runCommand(`where ${name}`, { timeoutMs: 4000 });
|
|
const pathLine = whereResult.ok ? firstOutputLine(whereResult.stdout || "") : "";
|
|
return { found: Boolean(pathLine), path: pathLine || "" };
|
|
}
|
|
const commandResult = await runCommand(`command -v ${name}`, { timeoutMs: 4000 });
|
|
let pathLine = commandResult.ok ? firstOutputLine(commandResult.stdout || "") : "";
|
|
if (!pathLine) {
|
|
const whichResult = await runCommand(`which ${name}`, { timeoutMs: 4000 });
|
|
pathLine = whichResult.ok ? firstOutputLine(whichResult.stdout || "") : "";
|
|
}
|
|
return { found: Boolean(pathLine), path: pathLine || "" };
|
|
}
|
|
|
|
function firstOutputLine(value) {
|
|
const lines = String(value || "").split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean);
|
|
return lines.length > 0 ? lines[0] : "";
|
|
}
|
|
|
|
function authorizeDebugRequest(req, res, url) {
|
|
if (!DEBUG_REMOTE_INTERFACE_ENABLED) {
|
|
sendError(res, 404, "debug.disabled", "Debug endpoint deaktiviert");
|
|
return null;
|
|
}
|
|
const settings = getDebugRemoteSettings();
|
|
const token = readDebugBearerToken(req);
|
|
if (token && token === settings.remoteToken) {
|
|
return { via: "token" };
|
|
}
|
|
const auth = requireAuth(req, res);
|
|
if (!auth) {
|
|
return null;
|
|
}
|
|
if (!hasRole(auth.user, ["admin"])) {
|
|
sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
|
|
return null;
|
|
}
|
|
return { via: "auth", user: auth.user };
|
|
}
|
|
|
|
function readDebugBearerToken(req) {
|
|
const header = String(req && req.headers ? (req.headers.authorization || "") : "").trim();
|
|
if (!header) {
|
|
return "";
|
|
}
|
|
const match = /^Bearer\s+(.+)$/i.exec(header);
|
|
return match ? String(match[1] || "").trim() : "";
|
|
}
|
|
|
|
function getDebugRemoteSettings() {
|
|
const pluginId = "rms.debug.remote";
|
|
const current = runtime.pluginState && runtime.pluginState.settings && runtime.pluginState.settings[pluginId]
|
|
? runtime.pluginState.settings[pluginId]
|
|
: {};
|
|
const collectLinesRaw = Number(current.collectLines);
|
|
const collectLines = Number.isFinite(collectLinesRaw) ? Math.max(100, Math.min(4000, Math.trunc(collectLinesRaw))) : 800;
|
|
const unitName = String(current.unitName || "remotestation-arcg").trim() || "remotestation-arcg";
|
|
const enabled = current.enabled !== false;
|
|
const redactSensitive = current.redactSensitive !== false;
|
|
const includePatternsRaw = String(current.includePatterns || "").trim();
|
|
const includePatterns = includePatternsRaw
|
|
? includePatternsRaw.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean)
|
|
: defaultDebugIncludePatterns("owrx");
|
|
const includePatternsUsbRaw = String(current.includePatternsUsb || "").trim();
|
|
const includePatternsUsb = includePatternsUsbRaw
|
|
? includePatternsUsbRaw.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean)
|
|
: defaultDebugIncludePatterns("usb");
|
|
const includePatternsAlsaRaw = String(current.includePatternsAlsa || "").trim();
|
|
const includePatternsAlsa = includePatternsAlsaRaw
|
|
? includePatternsAlsaRaw.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean)
|
|
: defaultDebugIncludePatterns("alsa");
|
|
const includePatternsSoapyRaw = String(current.includePatternsSoapy || "").trim();
|
|
const includePatternsSoapy = includePatternsSoapyRaw
|
|
? includePatternsSoapyRaw.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean)
|
|
: defaultDebugIncludePatterns("soapy");
|
|
return {
|
|
enabled,
|
|
remoteToken: String(current.remoteToken || DEBUG_REMOTE_DEFAULT_TOKEN).trim(),
|
|
collectLines,
|
|
includePatterns,
|
|
includePatternsUsb,
|
|
includePatternsAlsa,
|
|
includePatternsSoapy,
|
|
redactSensitive,
|
|
unitName
|
|
};
|
|
}
|
|
|
|
function defaultDebugIncludePatterns(scope) {
|
|
if (scope === "usb" || scope === "which" || scope === "alsa" || scope === "soapy") {
|
|
return [];
|
|
}
|
|
return [
|
|
"openwebrx/plugin/state",
|
|
"openwebrx/plugin/bands/select",
|
|
"openwebrx/plugin/audio/connect",
|
|
"openwebrx/rotor/status",
|
|
"upstream timed out",
|
|
"connection refused",
|
|
"prematurely closed"
|
|
];
|
|
}
|
|
|
|
function resolveDebugPaths(scope = "owrx") {
|
|
const normalizedScope = String(scope || "owrx").toLowerCase();
|
|
const safeScope = normalizedScope === "usb"
|
|
? "usb"
|
|
: (normalizedScope === "which"
|
|
? "which"
|
|
: (normalizedScope === "alsa" ? "alsa" : (normalizedScope === "soapy" ? "soapy" : "owrx")));
|
|
const debugDir = path.join(config.dataDir, "debug");
|
|
return {
|
|
debugDir,
|
|
logFilePath: path.join(debugDir, `${safeScope}-debug.log`),
|
|
snapshotFilePath: path.join(debugDir, `${safeScope}-debug-snapshot.json`)
|
|
};
|
|
}
|
|
|
|
async function collectDebugLogs(scope, settings, lineOverride = null) {
|
|
const normalizedScope = String(scope || "owrx").toLowerCase();
|
|
const safeScope = normalizedScope === "usb"
|
|
? "usb"
|
|
: (normalizedScope === "alsa" ? "alsa" : (normalizedScope === "soapy" ? "soapy" : "owrx"));
|
|
const paths = resolveDebugPaths(safeScope);
|
|
await fsp.mkdir(paths.debugDir, { recursive: true });
|
|
const collectLines = Number.isFinite(Number(lineOverride))
|
|
? Math.max(50, Math.min(4000, Math.trunc(Number(lineOverride))))
|
|
: settings.collectLines;
|
|
let collection;
|
|
if (safeScope === "usb") {
|
|
collection = await collectUsbDebugLogs(collectLines);
|
|
} else if (safeScope === "alsa") {
|
|
collection = await collectAlsaDebugLogs();
|
|
} else if (safeScope === "soapy") {
|
|
collection = await collectSoapyDebugLogs();
|
|
} else {
|
|
collection = await collectOpenWebRxDebugLogs(settings.unitName, collectLines);
|
|
}
|
|
const allLines = collection.lines;
|
|
const kept = filterDebugLines(
|
|
allLines,
|
|
safeScope === "usb"
|
|
? settings.includePatternsUsb
|
|
: (safeScope === "alsa"
|
|
? settings.includePatternsAlsa
|
|
: (safeScope === "soapy" ? settings.includePatternsSoapy : settings.includePatterns)),
|
|
settings.redactSensitive
|
|
);
|
|
const collectedAt = new Date().toISOString();
|
|
await fsp.writeFile(paths.logFilePath, kept.join("\n") + (kept.length ? "\n" : ""), "utf8");
|
|
const snapshot = safeScope === "usb"
|
|
? {
|
|
...buildDebugRuntimeSnapshot("usb"),
|
|
collectedAt,
|
|
scope: "usb",
|
|
collectLines,
|
|
totalLines: allLines.length,
|
|
keptLines: kept.length,
|
|
commands: collection.commands
|
|
}
|
|
: (safeScope === "alsa"
|
|
? {
|
|
...buildDebugRuntimeSnapshot("alsa"),
|
|
collectedAt,
|
|
scope: "alsa",
|
|
collectLines,
|
|
totalLines: allLines.length,
|
|
keptLines: kept.length,
|
|
commands: collection.commands
|
|
}
|
|
: (safeScope === "soapy"
|
|
? {
|
|
...buildDebugRuntimeSnapshot("soapy"),
|
|
collectedAt,
|
|
scope: "soapy",
|
|
collectLines,
|
|
totalLines: allLines.length,
|
|
keptLines: kept.length,
|
|
commands: collection.commands
|
|
}
|
|
: {
|
|
...buildDebugRuntimeSnapshot("owrx"),
|
|
collectedAt,
|
|
scope: "owrx",
|
|
collectLines,
|
|
totalLines: allLines.length,
|
|
keptLines: kept.length,
|
|
unitName: settings.unitName
|
|
}));
|
|
await fsp.writeFile(paths.snapshotFilePath, JSON.stringify(snapshot, null, 2), "utf8");
|
|
return {
|
|
scope: safeScope,
|
|
collectedAt,
|
|
totalLines: allLines.length,
|
|
keptLines: kept.length,
|
|
outputPath: paths.logFilePath,
|
|
snapshotPath: paths.snapshotFilePath
|
|
};
|
|
}
|
|
|
|
async function collectOpenWebRxDebugLogs(unitName, collectLines) {
|
|
const cmd = `sudo -n journalctl -u ${unitName} -n ${collectLines} --no-pager -o short-iso || journalctl -u ${unitName} -n ${collectLines} --no-pager -o short-iso`;
|
|
const result = await runCommand(cmd, { timeoutMs: 12000 });
|
|
const raw = String(result.stdout || result.stderr || "");
|
|
return {
|
|
lines: raw.split(/\r?\n/).filter(Boolean),
|
|
commands: [
|
|
{
|
|
command: cmd,
|
|
ok: Boolean(result.ok),
|
|
code: result.code
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
async function collectUsbDebugLogs(collectLines) {
|
|
const commands = [
|
|
"id",
|
|
"whoami",
|
|
"groups",
|
|
"command -v usbrelay",
|
|
"command -v rigctl",
|
|
"usbrelay --help",
|
|
"rigctl --version",
|
|
"ls -l /dev/rms-*",
|
|
"ls -l /dev/ttyUSB*",
|
|
"ls -l /dev/serial/by-id",
|
|
"lsusb",
|
|
"lsusb -t",
|
|
`sudo -n journalctl -k -n ${collectLines} --no-pager -o short-iso || journalctl -k -n ${collectLines} --no-pager -o short-iso`,
|
|
"sudo -n dmesg -T | tail -n 300 || dmesg -T | tail -n 300",
|
|
"sudo -n journalctl -u remotestation-arcg -n 300 --no-pager -o short-iso || journalctl -u remotestation-arcg -n 300 --no-pager -o short-iso"
|
|
];
|
|
const all = [];
|
|
const meta = [];
|
|
for (const command of commands) {
|
|
const result = await runCommand(command, { timeoutMs: 12000 });
|
|
const body = String(result.stdout || result.stderr || "").trim();
|
|
all.push(`[cmd] ${command}`);
|
|
all.push(`[ok=${result.ok ? "true" : "false"} code=${result.code}]`);
|
|
if (body) {
|
|
for (const line of body.split(/\r?\n/)) {
|
|
all.push(line);
|
|
}
|
|
}
|
|
all.push("");
|
|
meta.push({ command, ok: Boolean(result.ok), code: result.code });
|
|
}
|
|
return {
|
|
lines: all.filter((line, idx, arr) => line || (idx > 0 && arr[idx - 1])),
|
|
commands: meta
|
|
};
|
|
}
|
|
|
|
async function collectAlsaDebugLogs() {
|
|
const commands = [
|
|
"aplay -l",
|
|
"aplay -L",
|
|
"arecord -l",
|
|
"arecord -L",
|
|
"cat /proc/asound/cards",
|
|
"cat /proc/asound/devices"
|
|
];
|
|
const all = [];
|
|
const meta = [];
|
|
for (const command of commands) {
|
|
const result = await runCommand(command, { timeoutMs: 12000 });
|
|
const body = String(result.stdout || result.stderr || "").trim();
|
|
all.push(`[cmd] ${command}`);
|
|
all.push(`[ok=${result.ok ? "true" : "false"} code=${result.code}]`);
|
|
if (body) {
|
|
for (const line of body.split(/\r?\n/)) {
|
|
all.push(line);
|
|
}
|
|
}
|
|
all.push("");
|
|
meta.push({ command, ok: Boolean(result.ok), code: result.code });
|
|
}
|
|
return {
|
|
lines: all.filter((line, idx, arr) => line || (idx > 0 && arr[idx - 1])),
|
|
commands: meta
|
|
};
|
|
}
|
|
|
|
async function collectSoapyDebugLogs() {
|
|
const commands = [
|
|
"id",
|
|
"groups",
|
|
"lsusb -d 03eb:800c",
|
|
"soapy_connector --listdrivers",
|
|
"soapy_connector --listmodules",
|
|
"SoapySDRUtil --find=\"driver=airspyhf\"",
|
|
"SoapySDRUtil --find",
|
|
"ls -l /dev/bus/usb/*/*"
|
|
];
|
|
const all = [];
|
|
const meta = [];
|
|
for (const command of commands) {
|
|
const result = await runCommand(command, { timeoutMs: 12000 });
|
|
const body = String(result.stdout || result.stderr || "").trim();
|
|
all.push(`[cmd] ${command}`);
|
|
all.push(`[ok=${result.ok ? "true" : "false"} code=${result.code}]`);
|
|
if (body) {
|
|
for (const line of body.split(/\r?\n/)) {
|
|
all.push(line);
|
|
}
|
|
}
|
|
all.push("");
|
|
meta.push({ command, ok: Boolean(result.ok), code: result.code });
|
|
}
|
|
return {
|
|
lines: all.filter((line, idx, arr) => line || (idx > 0 && arr[idx - 1])),
|
|
commands: meta
|
|
};
|
|
}
|
|
|
|
function buildDebugRuntimeSnapshot(scope) {
|
|
if (scope === "usb") {
|
|
return {
|
|
ok: true,
|
|
scope: "usb",
|
|
collectedAt: null,
|
|
station: buildStationStatusView(),
|
|
usb: {
|
|
note: "USB snapshot from command dump"
|
|
}
|
|
};
|
|
}
|
|
if (scope === "which") {
|
|
return {
|
|
ok: true,
|
|
scope: "which",
|
|
collectedAt: null,
|
|
station: buildStationStatusView(),
|
|
which: {
|
|
note: "binary path checks"
|
|
}
|
|
};
|
|
}
|
|
if (scope === "alsa") {
|
|
return {
|
|
ok: true,
|
|
scope: "alsa",
|
|
collectedAt: null,
|
|
station: buildStationStatusView(),
|
|
alsa: {
|
|
note: "ALSA playback/capture device dump"
|
|
}
|
|
};
|
|
}
|
|
if (scope === "soapy") {
|
|
return {
|
|
ok: true,
|
|
scope: "soapy",
|
|
collectedAt: null,
|
|
station: buildStationStatusView(),
|
|
soapy: {
|
|
note: "SoapySDR and USB access diagnostics"
|
|
}
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
scope: "owrx",
|
|
collectedAt: null,
|
|
station: buildStationStatusView(),
|
|
openwebrx: {
|
|
pttActive: runtime.pttActive,
|
|
antennaRoute: runtime.openWebRxAntennaRoute || null,
|
|
session: runtime.openWebRxSession || null
|
|
},
|
|
txAudio: {
|
|
running: Boolean(runtime.txAudio && runtime.txAudio.running),
|
|
clients: runtime.txAudio && runtime.txAudio.clients ? runtime.txAudio.clients.size : 0,
|
|
lastError: runtime.txAudio ? runtime.txAudio.lastError || null : null,
|
|
lastExit: runtime.txAudio ? runtime.txAudio.lastExit || null : null
|
|
}
|
|
};
|
|
}
|
|
|
|
async function readDebugSnapshot(scope = "owrx") {
|
|
const paths = resolveDebugPaths(scope);
|
|
try {
|
|
const raw = await fsp.readFile(paths.snapshotFilePath, "utf8");
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function filterDebugLines(lines, includePatterns, redactSensitive) {
|
|
const patterns = Array.isArray(includePatterns)
|
|
? includePatterns.map((entry) => String(entry || "").toLowerCase()).filter(Boolean)
|
|
: [];
|
|
const out = [];
|
|
for (const line of lines) {
|
|
const normalized = String(line || "");
|
|
const lower = normalized.toLowerCase();
|
|
if (patterns.length > 0 && !patterns.some((entry) => lower.includes(entry))) {
|
|
continue;
|
|
}
|
|
out.push(redactSensitive ? redactDebugLine(normalized) : normalized);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function redactDebugLine(line) {
|
|
let next = String(line || "");
|
|
next = next.replace(/(accessToken=)[^\s&]+/gi, "$1[redacted]");
|
|
next = next.replace(/(ticket=)[^\s&]+/gi, "$1[redacted]");
|
|
next = next.replace(/(Authorization:\s*Bearer\s+)[A-Za-z0-9_\-.]+/gi, "$1[redacted]");
|
|
next = next.replace(/(eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.)[A-Za-z0-9_\-]+/g, "$1[redacted]");
|
|
return next;
|
|
}
|
|
|
|
async function handleUiControlAction(req, res, user, pathname, body) {
|
|
const parts = pathname.split("/").filter(Boolean);
|
|
const controlsIdx = parts.indexOf("controls");
|
|
const actionsIdx = parts.indexOf("actions");
|
|
if (controlsIdx === -1 || actionsIdx === -1 || actionsIdx <= controlsIdx + 1) {
|
|
return sendError(res, 404, "ui.control.not_found", "Control nicht gefunden");
|
|
}
|
|
|
|
const controlId = decodeURIComponent(parts[controlsIdx + 1]);
|
|
const action = decodeURIComponent(parts[actionsIdx + 1] || "");
|
|
const input = body && typeof body.input === "object" ? body.input : {};
|
|
|
|
if (controlId === "station-main") {
|
|
if (!hasRole(user, ["operator", "approver", "admin"])) {
|
|
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
|
|
}
|
|
if (action === "activate") {
|
|
return handleActivationStart(res, user);
|
|
}
|
|
if (action === "release") {
|
|
return handleStationRelease(res, user);
|
|
}
|
|
return sendError(res, 404, "ui.action.not_found", "Action nicht gefunden");
|
|
}
|
|
|
|
const controls = await buildUiControls(user);
|
|
const control = controls.find((entry) => entry.controlId === controlId);
|
|
if (!control) {
|
|
return sendError(res, 404, "ui.control.not_found", "Control nicht gefunden");
|
|
}
|
|
const descriptor = control.actions.find((entry) => entry.name === action);
|
|
if (!descriptor) {
|
|
return sendError(res, 404, "ui.action.not_found", "Action nicht gefunden");
|
|
}
|
|
|
|
if (!control.capabilityProvider || !control.capabilityProvider.capability) {
|
|
return sendError(res, 500, "ui.provider.missing", "Kein Plugin Provider zugewiesen");
|
|
}
|
|
if (!hasCapability(user, control.capabilityProvider.capability)) {
|
|
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
|
|
}
|
|
|
|
try {
|
|
const result = await executeCapability(control.capabilityProvider.capability, action, input, { user });
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
result: {
|
|
accepted: true,
|
|
message: result && result.message ? result.message : "Action ausgefuehrt",
|
|
...result
|
|
}
|
|
});
|
|
} catch (error) {
|
|
if (error && error.code === "TX_SWITCH_LOCK") {
|
|
return sendError(res, 409, "tx.switch_locked", String(error.message || error), error.details || null);
|
|
}
|
|
if (error && error.code === "VSWR_WHILE_STATION_ACTIVE") {
|
|
return sendError(res, 409, "vswr.unsafe_station_active", String(error.message || error));
|
|
}
|
|
return sendError(res, 500, "plugin.execute_failed", String(error.message || error));
|
|
}
|
|
}
|
|
|
|
async function handlePluginToggle(req, res, user, pathname) {
|
|
const match = pathname.match(/^\/v1\/plugins\/([^/]+)\/(enable|disable)$/);
|
|
if (!match) {
|
|
return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden");
|
|
}
|
|
const pluginId = decodeURIComponent(match[1]);
|
|
const action = match[2];
|
|
const plugin = runtime.plugins.get(pluginId);
|
|
if (!plugin) {
|
|
return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden");
|
|
}
|
|
|
|
if (action === "enable") {
|
|
runtime.pluginState.enabled[pluginId] = true;
|
|
if (plugin.instance.start) {
|
|
await plugin.instance.start();
|
|
}
|
|
} else {
|
|
runtime.pluginState.enabled[pluginId] = false;
|
|
if (plugin.instance.stop) {
|
|
await plugin.instance.stop();
|
|
}
|
|
}
|
|
|
|
await savePluginState();
|
|
await appendAudit(`plugin.${action}`, user, { pluginId });
|
|
broadcastEvent("plugin.enabled.changed", { pluginId, enabled: runtime.pluginState.enabled[pluginId] });
|
|
return sendJson(res, 200, { ok: true, plugin: describePlugin(plugin) });
|
|
}
|
|
|
|
function handlePluginSettingsSchema(res, pathname) {
|
|
const match = pathname.match(/^\/v1\/plugins\/([^/]+)\/settings-schema$/);
|
|
if (!match) {
|
|
return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden");
|
|
}
|
|
const pluginId = decodeURIComponent(match[1]);
|
|
const plugin = runtime.plugins.get(pluginId);
|
|
if (!plugin) {
|
|
return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden");
|
|
}
|
|
return sendJson(res, 200, {
|
|
pluginId,
|
|
settingsSchema: plugin.manifest.settingsSchema || { type: "object", properties: {} }
|
|
});
|
|
}
|
|
|
|
function handlePluginSettingsGet(res, pathname) {
|
|
const match = pathname.match(/^\/v1\/plugins\/([^/]+)\/settings$/);
|
|
if (!match) {
|
|
return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden");
|
|
}
|
|
const pluginId = decodeURIComponent(match[1]);
|
|
const plugin = runtime.plugins.get(pluginId);
|
|
if (!plugin) {
|
|
return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden");
|
|
}
|
|
return sendJson(res, 200, {
|
|
pluginId,
|
|
settings: runtime.pluginState.settings[pluginId] || {}
|
|
});
|
|
}
|
|
|
|
async function handlePluginSettingsPut(res, user, pathname, body) {
|
|
const match = pathname.match(/^\/v1\/plugins\/([^/]+)\/settings$/);
|
|
if (!match) {
|
|
return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden");
|
|
}
|
|
const pluginId = decodeURIComponent(match[1]);
|
|
const plugin = runtime.plugins.get(pluginId);
|
|
if (!plugin) {
|
|
return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden");
|
|
}
|
|
const nextSettings = body && typeof body.settings === "object" ? body.settings : null;
|
|
if (!nextSettings) {
|
|
return sendError(res, 400, "plugin.settings.invalid", "settings Objekt erforderlich");
|
|
}
|
|
|
|
const validation = validateSettings(plugin.manifest.settingsSchema || { type: "object", properties: {} }, nextSettings);
|
|
if (!validation.ok) {
|
|
return sendError(res, 400, "plugin.settings.invalid", validation.message);
|
|
}
|
|
|
|
runtime.pluginState.settings[pluginId] = nextSettings;
|
|
await savePluginState();
|
|
if (typeof plugin.instance.onSettingsUpdated === "function") {
|
|
await plugin.instance.onSettingsUpdated(nextSettings);
|
|
}
|
|
await appendAudit("plugin.settings.update", user, { pluginId });
|
|
broadcastEvent("plugin.settings.changed", { pluginId });
|
|
return sendJson(res, 200, { ok: true, pluginId, settings: nextSettings });
|
|
}
|
|
|
|
async function handleCapabilityProviderSwitch(res, user, pathname, body) {
|
|
const prefix = "/v1/admin/capabilities/";
|
|
const capability = decodeURIComponent(pathname.slice(prefix.length, pathname.length - "/provider".length));
|
|
const pluginId = typeof (body && body.pluginId) === "string" ? body.pluginId : "";
|
|
const dryRun = Boolean(body && body.dryRun);
|
|
const plugin = runtime.plugins.get(pluginId);
|
|
|
|
if (!capability || !pluginId) {
|
|
return sendError(res, 400, "provider.invalid_request", "capability und pluginId erforderlich");
|
|
}
|
|
if (!plugin) {
|
|
return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden");
|
|
}
|
|
if (!plugin.manifest.capabilities.includes(capability)) {
|
|
return sendError(res, 409, "provider.capability_mismatch", "Plugin unterstuetzt Capability nicht");
|
|
}
|
|
|
|
const health = await safePluginHealth(plugin);
|
|
if (dryRun) {
|
|
return sendJson(res, 200, {
|
|
capability,
|
|
activePluginId: runtime.pluginState.providers[capability] || null,
|
|
previousPluginId: runtime.pluginState.providers[capability] || null,
|
|
health,
|
|
switchedAt: new Date().toISOString()
|
|
});
|
|
}
|
|
|
|
const previous = runtime.pluginState.providers[capability] || null;
|
|
runtime.pluginState.providers[capability] = pluginId;
|
|
await savePluginState();
|
|
await appendAudit("provider.switch", user, { capability, pluginId, previous });
|
|
broadcastEvent("plugin.provider.changed", { capability, pluginId, previousPluginId: previous });
|
|
return sendJson(res, 200, {
|
|
capability,
|
|
activePluginId: pluginId,
|
|
previousPluginId: previous,
|
|
health,
|
|
switchedAt: new Date().toISOString()
|
|
});
|
|
}
|
|
|
|
function handleEventStream(req, res, url) {
|
|
const auth = requireAuth(req, res, { allowQueryToken: true, queryTokenUrl: url });
|
|
if (!auth) return;
|
|
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
Connection: "keep-alive"
|
|
});
|
|
res.write(`event: connected\n`);
|
|
res.write(`data: ${JSON.stringify({ ok: true, user: sanitizeUser(auth.user) })}\n\n`);
|
|
|
|
const client = { res, userId: auth.user.id };
|
|
runtime.sseClients.add(client);
|
|
|
|
req.on("close", () => {
|
|
runtime.sseClients.delete(client);
|
|
});
|
|
}
|
|
|
|
async function buildUiControls(user) {
|
|
const controls = [];
|
|
const activation = getCurrentActivationView();
|
|
|
|
if (user.role === "admin" || user.role === "approver" || user.role === "operator") {
|
|
controls.push({
|
|
controlId: "station-main",
|
|
controlType: "swr.progress",
|
|
title: "Stationssteuerung",
|
|
capabilityProvider: {
|
|
capability: "station.activate",
|
|
pluginId: runtime.pluginState.providers["station.activate"] || null
|
|
},
|
|
status: buildStationStatusView(),
|
|
viewSchema: {
|
|
showLinksAfterActivation: true
|
|
},
|
|
actions: [
|
|
{
|
|
name: "activate",
|
|
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
},
|
|
{
|
|
name: "release",
|
|
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
}
|
|
]
|
|
});
|
|
}
|
|
|
|
for (const plugin of runtime.plugins.values()) {
|
|
if (!runtime.pluginState.enabled[plugin.manifest.id]) {
|
|
continue;
|
|
}
|
|
const pluginControls = Array.isArray(plugin.manifest.uiControls) ? plugin.manifest.uiControls : [];
|
|
for (const control of pluginControls) {
|
|
const capability = control.capability || "";
|
|
const provider = runtime.pluginState.providers[capability] || null;
|
|
if (provider !== plugin.manifest.id) {
|
|
continue;
|
|
}
|
|
if (capability && !hasCapability(user, capability)) {
|
|
continue;
|
|
}
|
|
const pluginStatus = control.controlType === "swr.progress" ? activation : (plugin.instance.getStatus ? await safeStatus(plugin.instance) : {});
|
|
controls.push({
|
|
controlId: control.controlId,
|
|
controlType: control.controlType,
|
|
title: control.title,
|
|
capabilityProvider: {
|
|
capability,
|
|
pluginId: plugin.manifest.id
|
|
},
|
|
status: pluginStatus,
|
|
viewSchema: control.viewSchema || {},
|
|
actions: (control.actions || []).map((entry) => ({
|
|
name: entry.name,
|
|
inputSchema: entry.inputSchema || { type: "object", additionalProperties: true }
|
|
}))
|
|
});
|
|
}
|
|
}
|
|
|
|
return controls;
|
|
}
|
|
|
|
async function buildSwrReportView(user) {
|
|
let report = {
|
|
source: "none",
|
|
generatedAt: null,
|
|
overallStatus: "UNKNOWN",
|
|
bands: [],
|
|
overviewUrl: String(process.env.SWR_OVERVIEW_URL || "") || null
|
|
};
|
|
|
|
try {
|
|
const pluginReport = await executeCapability("vswr.report.read", "getReport", {}, { user });
|
|
if (pluginReport && typeof pluginReport === "object") {
|
|
report = {
|
|
...report,
|
|
...pluginReport,
|
|
bands: Array.isArray(pluginReport.bands) ? pluginReport.bands : []
|
|
};
|
|
}
|
|
} catch {
|
|
// optional capability
|
|
}
|
|
|
|
if (!report.generatedAt) {
|
|
report.generatedAt = runtime.station.updatedAt || null;
|
|
}
|
|
return report;
|
|
}
|
|
|
|
function buildStationStatusView() {
|
|
const activation = getCurrentActivationView();
|
|
const swrRun = getCurrentSwrRunView();
|
|
const links = {
|
|
swrOverview: String(process.env.SWR_OVERVIEW_URL || "") || null,
|
|
webSdr: String(process.env.WEBSDR_URL || "") || null,
|
|
rotorControl: String(process.env.ROTOR_CONTROL_URL || "") || null,
|
|
openWebRxPath: String(process.env.OPENWEBRX_PATH || "/sdr/") || "/sdr/"
|
|
};
|
|
const linksReady = runtime.station.isInUse && !activation.running;
|
|
const nowMs = Date.now();
|
|
const reservations = getNormalizedStationReservations(nowMs);
|
|
const activeReservation = findActiveReservationEntry(reservations, nowMs);
|
|
const activationReserved = Boolean(activation.running && !runtime.station.isInUse && activation.startedAt);
|
|
const effectiveStartedAt = runtime.station.isInUse
|
|
? (runtime.station.startedAt || null)
|
|
: (activeReservation ? activeReservation.from : (activationReserved ? String(activation.startedAt) : null));
|
|
const effectiveEndsAt = runtime.station.isInUse
|
|
? (runtime.station.endsAt || null)
|
|
: (activeReservation ? activeReservation.to : (activationReserved ? computeLeaseEndIso(effectiveStartedAt) : null));
|
|
const effectiveActiveByEmail = runtime.station.isInUse
|
|
? runtime.station.activeByEmail
|
|
: (activeReservation ? activeReservation.email : (activationReserved ? (activation.startedBy || null) : null));
|
|
const effectiveActiveByUserId = runtime.station.isInUse
|
|
? runtime.station.activeByUserId
|
|
: (activeReservation ? activeReservation.userId : (activationReserved ? (activation.startedByUserId || null) : null));
|
|
const remainingUsageSec = effectiveEndsAt
|
|
? Math.max(0, Math.ceil((new Date(effectiveEndsAt).getTime() - Date.now()) / 1000))
|
|
: 0;
|
|
const slotMs = stationUsageDurationMs();
|
|
const reservationEntries = reservations.map((entry, index) => {
|
|
return {
|
|
position: index + 1,
|
|
userId: entry.userId,
|
|
email: entry.email,
|
|
createdAt: entry.createdAt,
|
|
from: entry.from,
|
|
to: entry.to,
|
|
active: Boolean(activeReservation
|
|
&& String(activeReservation.userId || "") === String(entry.userId || "")
|
|
&& String(activeReservation.from || "") === String(entry.from || "")
|
|
&& String(activeReservation.to || "") === String(entry.to || ""))
|
|
};
|
|
});
|
|
const stationOccupied = Boolean(runtime.station.isInUse || activation.running);
|
|
const queueVisible = stationOccupied || reservationEntries.length > 0 || Boolean(activeReservation);
|
|
|
|
return {
|
|
stationName: runtime.station.stationName,
|
|
stationOnline: Boolean(runtime.station.stationOnline),
|
|
maintenanceMode: Boolean(runtime.systemState.maintenanceMode),
|
|
maintenanceMessage: runtime.systemState.maintenanceMessage,
|
|
isInUse: Boolean(runtime.station.isInUse),
|
|
activeByUserId: effectiveActiveByUserId,
|
|
activeByEmail: effectiveActiveByEmail,
|
|
startedAt: effectiveStartedAt,
|
|
endsAt: effectiveEndsAt,
|
|
remainingUsageSec,
|
|
updatedAt: runtime.station.updatedAt,
|
|
lastAction: runtime.station.lastAction,
|
|
execMode: config.execMode,
|
|
simulateHardware: config.simulateHardware,
|
|
openWebRxTxPollMs: Math.max(1000, Math.min(60000, Number(config.openWebRxTxPollMs || 4000))),
|
|
activation,
|
|
swrRun,
|
|
reservationQueue: {
|
|
slotDurationSec: Math.floor(slotMs / 1000),
|
|
canReserve: stationOccupied,
|
|
visible: queueVisible,
|
|
slotLockActive: Boolean(activeReservation),
|
|
activeEntry: activeReservation
|
|
? {
|
|
userId: activeReservation.userId,
|
|
email: activeReservation.email,
|
|
from: activeReservation.from,
|
|
to: activeReservation.to
|
|
}
|
|
: null,
|
|
entries: reservationEntries
|
|
},
|
|
links,
|
|
linksReady
|
|
};
|
|
}
|
|
|
|
function getExpectedSWRDurationMs() {
|
|
const configured = Number(getPluginSetting("rms.vswr.nanovna", "expectedDurationMs", process.env.SWR_CHECK_DURATION_MS || 54000));
|
|
if (!Number.isFinite(configured)) {
|
|
return 54000;
|
|
}
|
|
return Math.max(1000, Math.floor(configured));
|
|
}
|
|
|
|
function getCurrentSwrRunView() {
|
|
const current = runtime.swrRun || {};
|
|
if (!current.running) {
|
|
return {
|
|
running: false,
|
|
source: null,
|
|
phase: null,
|
|
percent: 0,
|
|
elapsedSec: 0,
|
|
remainingSec: 0,
|
|
startedAt: null,
|
|
startedBy: null,
|
|
lastStatus: current.lastStatus || null,
|
|
lastError: current.lastError || null,
|
|
lastFinishedAt: current.lastFinishedAt || null
|
|
};
|
|
}
|
|
const startedAtMs = Date.parse(String(current.startedAt || ""));
|
|
const elapsedMs = Number.isFinite(startedAtMs) ? Math.max(0, Date.now() - startedAtMs) : 0;
|
|
const expectedMs = Number.isFinite(Number(current.expectedDurationMs))
|
|
? Math.max(1, Math.floor(Number(current.expectedDurationMs)))
|
|
: getExpectedSWRDurationMs();
|
|
const percent = Math.max(0, Math.min(99, Math.floor((elapsedMs / Math.max(1, expectedMs)) * 100)));
|
|
return {
|
|
running: true,
|
|
source: current.source || null,
|
|
phase: current.phase || "swr-check",
|
|
percent,
|
|
elapsedSec: Math.floor(elapsedMs / 1000),
|
|
remainingSec: Math.max(0, Math.ceil((expectedMs - elapsedMs) / 1000)),
|
|
startedAt: current.startedAt || null,
|
|
startedBy: current.startedBy || null,
|
|
lastStatus: null,
|
|
lastError: null,
|
|
lastFinishedAt: null
|
|
};
|
|
}
|
|
|
|
function getCurrentActivationView() {
|
|
const job = runtime.currentActivationJobId ? runtime.jobs.get(runtime.currentActivationJobId) : null;
|
|
if (!job || job.status !== "running") {
|
|
const latest = getLatestActivationJob();
|
|
return {
|
|
running: false,
|
|
phase: null,
|
|
percent: 0,
|
|
elapsedSec: 0,
|
|
remainingSec: 0,
|
|
startedAt: null,
|
|
startedBy: null,
|
|
startedByUserId: null,
|
|
lastStatus: latest && latest.status ? String(latest.status) : null,
|
|
lastError: latest && latest.status === "failed" && latest.error ? String(latest.error) : null,
|
|
lastFinishedAt: latest && latest.finishedAt ? latest.finishedAt : null
|
|
};
|
|
}
|
|
const elapsedSec = Math.max(0, Math.floor((Date.now() - new Date(job.startedAt).getTime()) / 1000));
|
|
return {
|
|
running: true,
|
|
phase: job.phase,
|
|
percent: Number(job.percent || 0),
|
|
elapsedSec,
|
|
remainingSec: Number(job.etaSec || 0),
|
|
startedAt: job.startedAt || null,
|
|
startedBy: job.startedBy || null,
|
|
startedByUserId: job.startedByUserId || null,
|
|
lastStatus: String(job.status || "running"),
|
|
lastError: null,
|
|
lastFinishedAt: null
|
|
};
|
|
}
|
|
|
|
function getLatestActivationJob() {
|
|
let latest = null;
|
|
for (const candidate of runtime.jobs.values()) {
|
|
if (!candidate || candidate.type !== "station.activate") {
|
|
continue;
|
|
}
|
|
if (!latest) {
|
|
latest = candidate;
|
|
continue;
|
|
}
|
|
const candidateTime = Date.parse(candidate.finishedAt || candidate.startedAt || 0);
|
|
const latestTime = Date.parse(latest.finishedAt || latest.startedAt || 0);
|
|
if (Number.isFinite(candidateTime) && (!Number.isFinite(latestTime) || candidateTime >= latestTime)) {
|
|
latest = candidate;
|
|
}
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
async function startGlobalSwrRun(user, options = {}) {
|
|
return withLock(async () => {
|
|
if (runtime.swrRun && runtime.swrRun.running) {
|
|
const error = new Error("SWR-Check laeuft bereits");
|
|
error.code = "SWR_ALREADY_RUNNING";
|
|
throw error;
|
|
}
|
|
const token = `swr_${crypto.randomUUID()}`;
|
|
runtime.swrRun = {
|
|
...runtime.swrRun,
|
|
running: true,
|
|
token,
|
|
source: String(options.source || "manual"),
|
|
phase: String(options.phase || "swr-check"),
|
|
startedAt: new Date().toISOString(),
|
|
expectedDurationMs: Number.isFinite(Number(options.expectedDurationMs))
|
|
? Math.max(1000, Math.floor(Number(options.expectedDurationMs)))
|
|
: getExpectedSWRDurationMs(),
|
|
startedBy: user && user.email ? String(user.email) : null,
|
|
lastStatus: "running",
|
|
lastError: null,
|
|
lastFinishedAt: null
|
|
};
|
|
broadcastEvent("swr.run.started", {
|
|
source: runtime.swrRun.source,
|
|
startedBy: runtime.swrRun.startedBy
|
|
});
|
|
broadcastEvent("station.status.changed", buildStationStatusView());
|
|
return token;
|
|
});
|
|
}
|
|
|
|
async function finishGlobalSwrRun(token, options = {}) {
|
|
return withLock(async () => {
|
|
if (!runtime.swrRun || !runtime.swrRun.running) {
|
|
return;
|
|
}
|
|
if (!token || String(runtime.swrRun.token || "") !== String(token)) {
|
|
return;
|
|
}
|
|
const status = String(options.status || "succeeded");
|
|
runtime.swrRun = {
|
|
...runtime.swrRun,
|
|
running: false,
|
|
token: null,
|
|
source: null,
|
|
phase: null,
|
|
startedAt: null,
|
|
expectedDurationMs: 0,
|
|
startedBy: null,
|
|
lastStatus: status,
|
|
lastError: options.error ? String(options.error) : null,
|
|
lastFinishedAt: new Date().toISOString()
|
|
};
|
|
broadcastEvent("swr.run.finished", {
|
|
status,
|
|
error: runtime.swrRun.lastError,
|
|
finishedAt: runtime.swrRun.lastFinishedAt
|
|
});
|
|
broadcastEvent("station.status.changed", buildStationStatusView());
|
|
});
|
|
}
|
|
|
|
async function loadPlugins() {
|
|
runtime.plugins.clear();
|
|
if (!fs.existsSync(config.pluginDir)) {
|
|
await fsp.mkdir(config.pluginDir, { recursive: true });
|
|
return;
|
|
}
|
|
|
|
const entries = await fsp.readdir(config.pluginDir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) {
|
|
continue;
|
|
}
|
|
const pluginPath = path.join(config.pluginDir, entry.name);
|
|
const manifestPath = path.join(pluginPath, "manifest.json");
|
|
const indexPath = path.join(pluginPath, "index.js");
|
|
if (!fs.existsSync(manifestPath) || !fs.existsSync(indexPath)) {
|
|
continue;
|
|
}
|
|
const manifest = JSON.parse(await fsp.readFile(manifestPath, "utf8"));
|
|
const factory = require(indexPath);
|
|
const createPlugin = typeof factory.createPlugin === "function" ? factory.createPlugin : factory;
|
|
const instance = await createPlugin({
|
|
manifest,
|
|
rootDir,
|
|
env: process.env,
|
|
execMode: config.execMode,
|
|
simulateHardware: config.simulateHardware,
|
|
getSetting: (key, fallback = null) => getPluginSetting(manifest.id, key, fallback),
|
|
getPluginSetting: (pluginId, key, fallback = null) => getPluginSetting(pluginId, key, fallback),
|
|
commandRunner: runCommand,
|
|
executeCapability: async (capability, action, input = {}, meta = {}) => executeCapability(capability, action, input, meta),
|
|
emit: (type, data) => broadcastEvent(type, { pluginId: manifest.id, ...data }),
|
|
mailOutboxPath: files.mailOutbox,
|
|
appendMailOutbox: async (entry) => {
|
|
await appendMailOutboxEntry(entry);
|
|
}
|
|
});
|
|
|
|
runtime.plugins.set(manifest.id, {
|
|
manifest,
|
|
instance,
|
|
pluginPath
|
|
});
|
|
}
|
|
|
|
for (const [id, plugin] of runtime.plugins.entries()) {
|
|
if (!(id in runtime.pluginState.enabled)) {
|
|
runtime.pluginState.enabled[id] = plugin && plugin.manifest && typeof plugin.manifest.defaultEnabled === "boolean"
|
|
? plugin.manifest.defaultEnabled
|
|
: true;
|
|
}
|
|
if (runtime.pluginState.enabled[id] && plugin.instance.start) {
|
|
await plugin.instance.start();
|
|
}
|
|
runtime.pluginHealth[id] = await safePluginHealth(plugin);
|
|
}
|
|
|
|
ensureDefaultProviders();
|
|
await savePluginState();
|
|
}
|
|
|
|
function ensureDefaultProviders() {
|
|
const byCapability = new Map();
|
|
for (const plugin of runtime.plugins.values()) {
|
|
for (const capability of plugin.manifest.capabilities || []) {
|
|
if (!byCapability.has(capability)) {
|
|
byCapability.set(capability, []);
|
|
}
|
|
byCapability.get(capability).push(plugin.manifest.id);
|
|
}
|
|
}
|
|
|
|
for (const [capability, pluginIds] of byCapability.entries()) {
|
|
const preferred = preferredProviderForCapability(capability, pluginIds);
|
|
if (preferred) {
|
|
runtime.pluginState.providers[capability] = preferred;
|
|
continue;
|
|
}
|
|
const current = runtime.pluginState.providers[capability];
|
|
if (current && pluginIds.includes(current)) {
|
|
continue;
|
|
}
|
|
runtime.pluginState.providers[capability] = pluginIds[0];
|
|
}
|
|
}
|
|
|
|
function preferredProviderForCapability(capability, pluginIds) {
|
|
if (!Array.isArray(pluginIds) || pluginIds.length === 0) {
|
|
return null;
|
|
}
|
|
if ((capability === "tx.control" || capability === "tx.state.read") && pluginIds.includes("rms.tx.control.native")) {
|
|
return "rms.tx.control.native";
|
|
}
|
|
if (capability === "tx.audio" && pluginIds.includes("rms.tx.audio.core")) {
|
|
return "rms.tx.audio.core";
|
|
}
|
|
if (capability === "tx.audio.backend" && pluginIds.includes("rms.microham")) {
|
|
return "rms.microham";
|
|
}
|
|
if ((capability === "vswr.run" || capability === "vswr.report.read") && pluginIds.includes("rms.vswr.native")) {
|
|
return "rms.vswr.native";
|
|
}
|
|
if ((capability === "openwebrx.access.issue" || capability === "openwebrx.access.verify" || capability === "openwebrx.service.control")
|
|
&& pluginIds.includes("rms.openwebrx.guard")) {
|
|
return "rms.openwebrx.guard";
|
|
}
|
|
if ((capability === "openwebrx.band.read" || capability === "openwebrx.band.set") && pluginIds.includes("rms.openwebrx.bandmap")) {
|
|
return "rms.openwebrx.bandmap";
|
|
}
|
|
if ((capability === "station.access.policy.read" || capability === "admin.station.access.policy.write")
|
|
&& pluginIds.includes("rms.station.access.policy")) {
|
|
return "rms.station.access.policy";
|
|
}
|
|
if (capability === "help.content.read" && pluginIds.includes("rms.help.basic")) {
|
|
return "rms.help.basic";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function describePlugin(plugin) {
|
|
return {
|
|
id: plugin.manifest.id,
|
|
name: plugin.manifest.name,
|
|
version: plugin.manifest.version,
|
|
capabilities: plugin.manifest.capabilities || [],
|
|
authMethod: plugin.manifest.authMethod || null,
|
|
settingsSchema: plugin.manifest.settingsSchema || { type: "object", properties: {} },
|
|
settings: runtime.pluginState.settings[plugin.manifest.id] || {},
|
|
enabled: Boolean(runtime.pluginState.enabled[plugin.manifest.id]),
|
|
health: runtime.pluginHealth[plugin.manifest.id] || "unknown",
|
|
providerFor: Object.entries(runtime.pluginState.providers)
|
|
.filter(([, pluginId]) => pluginId === plugin.manifest.id)
|
|
.map(([capability]) => capability)
|
|
};
|
|
}
|
|
|
|
async function listPlugins() {
|
|
await refreshPluginHealth();
|
|
return Array.from(runtime.plugins.values()).map((plugin) => describePlugin(plugin));
|
|
}
|
|
|
|
async function buildCapabilitiesMatrix() {
|
|
await refreshPluginHealth();
|
|
const map = new Map();
|
|
for (const plugin of runtime.plugins.values()) {
|
|
for (const capability of plugin.manifest.capabilities || []) {
|
|
if (!map.has(capability)) {
|
|
map.set(capability, {
|
|
capability,
|
|
activePluginId: runtime.pluginState.providers[capability] || null,
|
|
providers: []
|
|
});
|
|
}
|
|
map.get(capability).providers.push({
|
|
pluginId: plugin.manifest.id,
|
|
enabled: Boolean(runtime.pluginState.enabled[plugin.manifest.id]),
|
|
health: runtime.pluginHealth[plugin.manifest.id] || "unknown"
|
|
});
|
|
}
|
|
}
|
|
return Array.from(map.values()).sort((a, b) => a.capability.localeCompare(b.capability));
|
|
}
|
|
|
|
async function executeCapability(capability, action, input, meta) {
|
|
if (runtime.pttActive && capability === "rfroute.set" && action === "setRoute") {
|
|
const route = String(input && input.route ? input.route : "").toLowerCase();
|
|
if (route && route !== "tx" && !(meta && meta.allowPttOverride)) {
|
|
const error = new Error("PTT aktiv: Antennenweg bleibt auf TX gesperrt");
|
|
error.code = "PTT_ROUTE_LOCK";
|
|
throw error;
|
|
}
|
|
}
|
|
if (isSwitchingCapability(capability, action) && !(meta && meta.skipTxSafety)) {
|
|
const txState = await readTransmitState(meta && meta.user ? meta.user : null);
|
|
if (txState.txActive) {
|
|
const error = new Error("Umschalten ist waehrend aktivem Senden gesperrt");
|
|
error.code = "TX_SWITCH_LOCK";
|
|
error.details = txState;
|
|
throw error;
|
|
}
|
|
}
|
|
if (capability === "vswr.run" && isOpenWebRxSessionActive()) {
|
|
const error = new Error("SWR-Check ist gesperrt solange OpenWebRX aktiv genutzt wird");
|
|
error.code = "OPENWEBRX_SESSION_ACTIVE";
|
|
throw error;
|
|
}
|
|
if (capability === "vswr.run" && runtime.station && runtime.station.isInUse) {
|
|
const error = new Error("SWR-Check ist gesperrt solange die Station aktiv ist");
|
|
error.code = "VSWR_WHILE_STATION_ACTIVE";
|
|
throw error;
|
|
}
|
|
const pluginId = runtime.pluginState.providers[capability];
|
|
if (!pluginId) {
|
|
throw new Error(`No provider for capability ${capability}`);
|
|
}
|
|
const plugin = runtime.plugins.get(pluginId);
|
|
if (!plugin) {
|
|
throw new Error(`Provider plugin ${pluginId} missing`);
|
|
}
|
|
if (!runtime.pluginState.enabled[pluginId]) {
|
|
throw new Error(`Provider plugin ${pluginId} disabled`);
|
|
}
|
|
if (typeof plugin.instance.execute !== "function") {
|
|
throw new Error(`Provider plugin ${pluginId} has no execute()`);
|
|
}
|
|
return plugin.instance.execute(action, input || {}, meta || {});
|
|
}
|
|
|
|
function isOpenWebRxSessionActive() {
|
|
const session = runtime.openWebRxSession || {};
|
|
if (!session.activeOwnerUserId) {
|
|
return false;
|
|
}
|
|
if (!session.expiresAtMs || session.expiresAtMs <= Date.now()) {
|
|
if (runtime.station
|
|
&& runtime.station.isInUse
|
|
&& runtime.station.activeByUserId
|
|
&& String(runtime.station.activeByUserId) === String(session.activeOwnerUserId)) {
|
|
const ttlSec = Number(process.env.OPENWEBRX_TICKET_TTL_SEC || 3600);
|
|
const effectiveTtlSec = Number.isFinite(ttlSec) ? Math.max(60, ttlSec) : 120;
|
|
runtime.openWebRxSession.expiresAtMs = Date.now() + effectiveTtlSec * 1000;
|
|
return true;
|
|
}
|
|
clearOpenWebRxSession();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function markOpenWebRxSession(user, sessionResult) {
|
|
const ownerUserId = user && user.id ? String(user.id) : String(sessionResult && sessionResult.userId ? sessionResult.userId : "");
|
|
const expiresAtMs = Date.parse(String(sessionResult && sessionResult.expiresAt ? sessionResult.expiresAt : ""));
|
|
runtime.openWebRxSession = {
|
|
activeOwnerUserId: ownerUserId || null,
|
|
expiresAtMs: Number.isFinite(expiresAtMs) ? expiresAtMs : (Date.now() + 120000),
|
|
lastEnsureSdrAtMs: runtime.openWebRxSession && Number.isFinite(runtime.openWebRxSession.lastEnsureSdrAtMs)
|
|
? runtime.openWebRxSession.lastEnsureSdrAtMs
|
|
: 0
|
|
};
|
|
}
|
|
|
|
function clearOpenWebRxSession() {
|
|
const previousOwnerUserId = runtime.openWebRxSession && runtime.openWebRxSession.activeOwnerUserId
|
|
? String(runtime.openWebRxSession.activeOwnerUserId)
|
|
: "";
|
|
runtime.openWebRxSession = {
|
|
activeOwnerUserId: null,
|
|
expiresAtMs: 0,
|
|
lastEnsureSdrAtMs: 0
|
|
};
|
|
runtime.openWebRxAntennaRoute = null;
|
|
if (previousOwnerUserId) {
|
|
delete runtime.openWebRxLiveStateByUserId[previousOwnerUserId];
|
|
}
|
|
executeCapability("tx.audio", "audioDisconnect", { reason: "session-cleared" }, { skipTxSafety: true }).catch(() => {});
|
|
}
|
|
|
|
async function ensureOpenWebRxSdrPath(user, options = {}) {
|
|
const provider = runtime.pluginState.providers["openwebrx.service.control"];
|
|
if (!provider) {
|
|
return;
|
|
}
|
|
const force = Boolean(options.force);
|
|
const minIntervalMs = Number.isFinite(Number(options.minIntervalMs)) ? Number(options.minIntervalMs) : 3000;
|
|
const last = runtime.openWebRxSession && Number.isFinite(runtime.openWebRxSession.lastEnsureSdrAtMs)
|
|
? runtime.openWebRxSession.lastEnsureSdrAtMs
|
|
: 0;
|
|
if (!force && Date.now() - last < Math.max(0, minIntervalMs)) {
|
|
return;
|
|
}
|
|
await executeCapability("openwebrx.service.control", "ensureSdrPath", { reason: "openwebrx-session" }, { user, skipTxSafety: true });
|
|
runtime.openWebRxSession.lastEnsureSdrAtMs = Date.now();
|
|
}
|
|
|
|
function isSwitchingCapability(capability, action) {
|
|
if (capability === "rfroute.set") {
|
|
return action === "setRoute";
|
|
}
|
|
return capability === "station.activate"
|
|
|| capability === "station.deactivate"
|
|
|| capability === "vswr.run";
|
|
}
|
|
|
|
function shouldAutoDisableTxBeforeActivation() {
|
|
const value = String(config.autoDisableTxBeforeActivation || "false").trim().toLowerCase();
|
|
return value === "1" || value === "true" || value === "yes" || value === "on";
|
|
}
|
|
|
|
async function readTransmitState(user) {
|
|
const provider = runtime.pluginState.providers["tx.state.read"];
|
|
if (!provider) {
|
|
return { txActive: false, source: "none" };
|
|
}
|
|
try {
|
|
const state = await executeCapability("tx.state.read", "getTxState", {}, { user, skipTxSafety: true });
|
|
return {
|
|
txActive: Boolean(state && state.txActive),
|
|
source: state && state.source ? state.source : provider,
|
|
updatedAt: state && state.updatedAt ? state.updatedAt : null
|
|
};
|
|
} catch {
|
|
return { txActive: false, source: provider };
|
|
}
|
|
}
|
|
|
|
function requireAuth(req, res, options = {}) {
|
|
const token = getBearerToken(req) || (options.allowQueryToken && options.queryTokenUrl ? options.queryTokenUrl.searchParams.get("accessToken") : "");
|
|
if (!token) {
|
|
sendError(res, 401, "auth.missing_token", "Token fehlt");
|
|
return null;
|
|
}
|
|
const verified = verifyJwt(token, "access");
|
|
if (!verified.ok) {
|
|
sendError(res, 401, "auth.invalid_token", "Token ungueltig");
|
|
return null;
|
|
}
|
|
const payload = verified.payload;
|
|
const user = runtime.users.find((entry) => entry.id === payload.sub);
|
|
if (!user) {
|
|
sendError(res, 401, "auth.invalid_token", "Token ungueltig");
|
|
return null;
|
|
}
|
|
const tokenVersion = runtime.authState.tokenVersionByUser[user.id] || 0;
|
|
if (payload.tv !== tokenVersion) {
|
|
sendError(res, 401, "auth.invalid_token", "Token ungueltig");
|
|
return null;
|
|
}
|
|
if (user.status !== "active") {
|
|
sendError(res, 403, "auth.not_approved", "Benutzer ist nicht freigegeben");
|
|
return null;
|
|
}
|
|
if (runtime.systemState.maintenanceMode && user.role !== "admin") {
|
|
sendError(res, 503, "auth.maintenance", runtime.systemState.maintenanceMessage);
|
|
return null;
|
|
}
|
|
return { user, payload };
|
|
}
|
|
|
|
function getBearerToken(req) {
|
|
const header = req.headers.authorization || "";
|
|
const match = /^Bearer\s+(.+)$/i.exec(header);
|
|
return match ? match[1].trim() : "";
|
|
}
|
|
|
|
async function issueTokenPair(user, req, options = {}) {
|
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
const sid = options.sid || crypto.randomUUID();
|
|
const rid = crypto.randomUUID();
|
|
const tokenVersion = runtime.authState.tokenVersionByUser[user.id] || 0;
|
|
|
|
const effectiveAccessTtlSec = getEffectiveAccessTokenTtlSec(user, nowSec);
|
|
|
|
const accessPayload = {
|
|
sub: user.id,
|
|
email: user.email,
|
|
role: user.role,
|
|
sid,
|
|
tv: tokenVersion,
|
|
typ: "access",
|
|
iat: nowSec,
|
|
exp: nowSec + effectiveAccessTtlSec,
|
|
iss: config.jwtIssuer,
|
|
aud: config.jwtAudience
|
|
};
|
|
const refreshPayload = {
|
|
sub: user.id,
|
|
sid,
|
|
rid,
|
|
tv: tokenVersion,
|
|
typ: "refresh",
|
|
iat: nowSec,
|
|
exp: nowSec + config.refreshTokenTtlSec,
|
|
iss: config.jwtIssuer,
|
|
aud: config.jwtAudience
|
|
};
|
|
|
|
const accessToken = signJwt(accessPayload);
|
|
const refreshToken = signJwt(refreshPayload);
|
|
|
|
runtime.authState.refreshTokens.push({
|
|
id: rid,
|
|
sid,
|
|
userId: user.id,
|
|
tokenHash: sha256(refreshToken),
|
|
createdAt: new Date().toISOString(),
|
|
expiresAtMs: refreshPayload.exp * 1000,
|
|
revokedAt: null,
|
|
rotatedFrom: options.rotatedFrom || null,
|
|
userAgent: req.headers["user-agent"] || "",
|
|
ip: clientIp(req)
|
|
});
|
|
|
|
pruneRefreshTokens();
|
|
await saveAuthState();
|
|
|
|
return {
|
|
sid,
|
|
rid,
|
|
accessToken,
|
|
refreshToken
|
|
};
|
|
}
|
|
|
|
function getEffectiveAccessTokenTtlSec(user, nowSec) {
|
|
return getEffectiveAccessTokenTtlDetails(user, nowSec).effectiveTtlSec;
|
|
}
|
|
|
|
function getEffectiveAccessTokenTtlDetails(user, nowSec) {
|
|
const minimumTtlSec = 3 * 60 * 60;
|
|
const configured = Number.isFinite(Number(config.accessTokenTtlSec)) ? Math.floor(Number(config.accessTokenTtlSec)) : minimumTtlSec;
|
|
let ttl = Math.max(minimumTtlSec, configured);
|
|
|
|
const station = runtime.station;
|
|
const isOwnerActive = Boolean(
|
|
station &&
|
|
station.isInUse &&
|
|
station.activeByUserId &&
|
|
user &&
|
|
String(station.activeByUserId) === String(user.id)
|
|
);
|
|
if (!isOwnerActive) {
|
|
return {
|
|
effectiveTtlSec: ttl,
|
|
minimumTtlSec,
|
|
configuredAccessTokenTtlSec: configured,
|
|
ownerSessionBoostApplied: false
|
|
};
|
|
}
|
|
|
|
const endsAtMs = Date.parse(String(station.endsAt || ""));
|
|
if (!Number.isFinite(endsAtMs)) {
|
|
return {
|
|
effectiveTtlSec: ttl,
|
|
minimumTtlSec,
|
|
configuredAccessTokenTtlSec: configured,
|
|
ownerSessionBoostApplied: false
|
|
};
|
|
}
|
|
|
|
const remainingSec = Math.max(0, Math.ceil((endsAtMs - (nowSec * 1000)) / 1000));
|
|
const safetyBufferSec = 15 * 60;
|
|
const maxTtlSec = 24 * 60 * 60;
|
|
const boostedTtl = Math.min(maxTtlSec, Math.max(ttl, remainingSec + safetyBufferSec));
|
|
return {
|
|
effectiveTtlSec: boostedTtl,
|
|
minimumTtlSec,
|
|
configuredAccessTokenTtlSec: configured,
|
|
ownerSessionBoostApplied: boostedTtl > ttl
|
|
};
|
|
}
|
|
|
|
function signJwt(payload) {
|
|
const header = {
|
|
alg: "HS256",
|
|
typ: "JWT"
|
|
};
|
|
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
|
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
|
const body = `${encodedHeader}.${encodedPayload}`;
|
|
const signature = crypto.createHmac("sha256", config.jwtSecret).update(body).digest("base64url");
|
|
return `${body}.${signature}`;
|
|
}
|
|
|
|
function verifyJwt(token, expectedType) {
|
|
try {
|
|
const parts = token.split(".");
|
|
if (parts.length !== 3) {
|
|
return { ok: false };
|
|
}
|
|
const [encodedHeader, encodedPayload, signature] = parts;
|
|
const body = `${encodedHeader}.${encodedPayload}`;
|
|
const expectedSignature = crypto.createHmac("sha256", config.jwtSecret).update(body).digest("base64url");
|
|
if (!safeEquals(signature, expectedSignature)) {
|
|
return { ok: false };
|
|
}
|
|
|
|
const payload = JSON.parse(base64UrlDecode(encodedPayload));
|
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
if (payload.exp && nowSec > payload.exp) {
|
|
return { ok: false };
|
|
}
|
|
if (payload.iss !== config.jwtIssuer || payload.aud !== config.jwtAudience) {
|
|
return { ok: false };
|
|
}
|
|
if (expectedType && payload.typ !== expectedType) {
|
|
return { ok: false };
|
|
}
|
|
return { ok: true, payload };
|
|
} catch {
|
|
return { ok: false };
|
|
}
|
|
}
|
|
|
|
function hasRole(user, roles) {
|
|
return roles.includes(user.role);
|
|
}
|
|
|
|
function denyIfStationOwnedByOther(res, user, actionLabel, options = {}) {
|
|
const allowAdmin = Boolean(options.allowAdmin);
|
|
if (!runtime.station || !runtime.station.isInUse || !runtime.station.activeByUserId) {
|
|
return false;
|
|
}
|
|
if (allowAdmin && user && user.role === "admin") {
|
|
return false;
|
|
}
|
|
const isOwner = user && String(user.id || "") === String(runtime.station.activeByUserId || "");
|
|
if (isOwner) {
|
|
return false;
|
|
}
|
|
sendError(res, 409, "station.in_use", `${String(actionLabel || "Aktion")} ist gesperrt: Station wird von einem anderen Benutzer verwendet`);
|
|
return true;
|
|
}
|
|
|
|
function hasCapability(user, capability) {
|
|
if (!capability) return true;
|
|
if (user.role === "admin") return true;
|
|
if (capability.startsWith("admin.")) return false;
|
|
return true;
|
|
}
|
|
|
|
function userCapabilities(user) {
|
|
const caps = new Set();
|
|
for (const [capability, pluginId] of Object.entries(runtime.pluginState.providers)) {
|
|
if (!pluginId) continue;
|
|
if (hasCapability(user, capability)) {
|
|
caps.add(capability);
|
|
}
|
|
}
|
|
return Array.from(caps).sort();
|
|
}
|
|
|
|
function broadcastEvent(type, data) {
|
|
const event = {
|
|
type,
|
|
ts: new Date().toISOString(),
|
|
...data
|
|
};
|
|
const id = `evt_${++runtime.eventSeq}`;
|
|
const payload = `event: ${type}\nid: ${id}\ndata: ${JSON.stringify(event)}\n\n`;
|
|
for (const client of runtime.sseClients) {
|
|
try {
|
|
client.res.write(payload);
|
|
} catch {
|
|
runtime.sseClients.delete(client);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runCommand(commandString, options = {}) {
|
|
const parsed = splitCommand(commandString);
|
|
if (parsed.length === 0) {
|
|
return { ok: false, code: -1, stderr: "empty command" };
|
|
}
|
|
const [command, ...args] = parsed;
|
|
const timeoutMs = Number(options.timeoutMs || 120000);
|
|
|
|
return new Promise((resolve) => {
|
|
const child = spawn(command, args, {
|
|
cwd: options.cwd || rootDir,
|
|
env: {
|
|
...process.env,
|
|
...(options.env || {})
|
|
},
|
|
shell: true
|
|
});
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
const timer = setTimeout(() => {
|
|
child.kill("SIGTERM");
|
|
}, timeoutMs);
|
|
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += String(chunk);
|
|
if (stdout.length > 20000) stdout = stdout.slice(-20000);
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += String(chunk);
|
|
if (stderr.length > 20000) stderr = stderr.slice(-20000);
|
|
});
|
|
child.on("error", (error) => {
|
|
clearTimeout(timer);
|
|
resolve({ ok: false, code: -1, error: String(error.message || error), stdout: stdout.trim(), stderr: stderr.trim() });
|
|
});
|
|
child.on("close", (code) => {
|
|
clearTimeout(timer);
|
|
resolve({ ok: code === 0, code, stdout: stdout.trim(), stderr: stderr.trim() });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function ensureDataFiles() {
|
|
await fsp.mkdir(config.dataDir, { recursive: true });
|
|
if (!(await storage.exists(files.users))) {
|
|
await writeJson(files.users, []);
|
|
}
|
|
if (!(await storage.exists(files.station))) {
|
|
await writeJson(files.station, buildDefaultStationState());
|
|
}
|
|
if (!(await storage.exists(files.audit))) {
|
|
await storage.writeText(files.audit, "");
|
|
}
|
|
if (!(await storage.exists(files.auth))) {
|
|
await writeJson(files.auth, runtime.authState);
|
|
}
|
|
if (!(await storage.exists(files.plugins))) {
|
|
await writeJson(files.plugins, runtime.pluginState);
|
|
}
|
|
if (!(await storage.exists(files.approvals))) {
|
|
await writeJson(files.approvals, []);
|
|
}
|
|
if (!(await storage.exists(files.system))) {
|
|
await writeJson(files.system, runtime.systemState);
|
|
}
|
|
await ensureMailOutboxInitialized();
|
|
}
|
|
|
|
function buildDefaultStationState() {
|
|
return {
|
|
stationName: config.stationName,
|
|
stationOnline: true,
|
|
isInUse: false,
|
|
activeByUserId: null,
|
|
activeByEmail: null,
|
|
startedAt: null,
|
|
endsAt: null,
|
|
reservations: [],
|
|
manualReleaseReservationUserId: null,
|
|
manualReleaseReservationUntil: null,
|
|
updatedAt: new Date().toISOString(),
|
|
lastAction: "init"
|
|
};
|
|
}
|
|
|
|
async function applyAdminRoles() {
|
|
let changed = false;
|
|
if (!Array.isArray(runtime.authState.emailTokens)) {
|
|
runtime.authState.emailTokens = [];
|
|
changed = true;
|
|
}
|
|
if (!Array.isArray(runtime.authState.otpChallenges)) {
|
|
runtime.authState.otpChallenges = [];
|
|
changed = true;
|
|
}
|
|
if (!Array.isArray(runtime.authState.oauthChallenges)) {
|
|
runtime.authState.oauthChallenges = [];
|
|
changed = true;
|
|
}
|
|
const availableMethods = listPublicAuthMethods();
|
|
const availableMethodIds = availableMethods.map((entry) => entry.id);
|
|
const defaultMethodId = preferredAuthMethodId(availableMethods);
|
|
for (const user of runtime.users) {
|
|
const role = config.adminEmails.has(user.email)
|
|
? "admin"
|
|
: (config.approverEmails.has(user.email) ? "approver" : (user.role || "operator"));
|
|
if (user.role !== role) {
|
|
user.role = role;
|
|
changed = true;
|
|
}
|
|
if (!user.accountType) {
|
|
user.accountType = isPrimaryDomainEmail(user.email) ? "primary-domain" : "external-domain";
|
|
changed = true;
|
|
}
|
|
if (!Array.isArray(user.enabledAuthMethods) || user.enabledAuthMethods.length === 0) {
|
|
user.enabledAuthMethods = [...availableMethodIds];
|
|
changed = true;
|
|
}
|
|
const filteredMethods = user.enabledAuthMethods.filter((methodId) => availableMethodIds.includes(methodId));
|
|
if (filteredMethods.length !== user.enabledAuthMethods.length) {
|
|
user.enabledAuthMethods = filteredMethods;
|
|
changed = true;
|
|
}
|
|
if (!user.primaryAuthMethod || !user.enabledAuthMethods.includes(user.primaryAuthMethod)) {
|
|
user.primaryAuthMethod = defaultMethodId && user.enabledAuthMethods.includes(defaultMethodId)
|
|
? defaultMethodId
|
|
: (user.enabledAuthMethods[0] || null);
|
|
changed = true;
|
|
}
|
|
if (!user.status) {
|
|
user.status = "active";
|
|
changed = true;
|
|
}
|
|
if (!user.preferredLanguage || !["de", "en"].includes(String(user.preferredLanguage).toLowerCase())) {
|
|
user.preferredLanguage = "de";
|
|
changed = true;
|
|
}
|
|
if (user.role === "admin" && user.status !== "active") {
|
|
user.status = "active";
|
|
user.approvedAt = user.approvedAt || new Date().toISOString();
|
|
changed = true;
|
|
}
|
|
if (user.status === "active" && !user.emailVerifiedAt) {
|
|
user.emailVerifiedAt = user.createdAt || new Date().toISOString();
|
|
changed = true;
|
|
}
|
|
if (!(user.id in runtime.authState.tokenVersionByUser)) {
|
|
runtime.authState.tokenVersionByUser[user.id] = 0;
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) {
|
|
await writeJson(files.users, runtime.users);
|
|
await saveAuthState();
|
|
}
|
|
}
|
|
|
|
async function appendAudit(action, user, details) {
|
|
const entry = {
|
|
at: new Date().toISOString(),
|
|
action,
|
|
userId: user ? user.id : null,
|
|
email: user ? user.email : null,
|
|
details: details || null
|
|
};
|
|
await storage.appendText(files.audit, `${JSON.stringify(entry)}\n`);
|
|
}
|
|
|
|
async function listStationActivityLog(limit = 200) {
|
|
const raw = await storage.readText(files.audit, "");
|
|
const lines = raw
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
|
|
const interesting = new Set([
|
|
"auth.request_access",
|
|
"station.activate.start",
|
|
"station.activate.done",
|
|
"station.activate.failed",
|
|
"station.deactivate",
|
|
"station.deactivate.timeout"
|
|
]);
|
|
|
|
const entries = [];
|
|
for (const line of lines) {
|
|
let entry;
|
|
try {
|
|
entry = JSON.parse(line);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!entry || !interesting.has(entry.action)) {
|
|
continue;
|
|
}
|
|
entries.push({
|
|
at: entry.at,
|
|
action: entry.action,
|
|
email: entry.email || null,
|
|
details: entry.details || null,
|
|
message: activityMessageForEntry(entry)
|
|
});
|
|
}
|
|
|
|
entries.sort((a, b) => new Date(b.at || 0).getTime() - new Date(a.at || 0).getTime());
|
|
return entries.slice(0, limit);
|
|
}
|
|
|
|
function activityMessageForEntry(entry) {
|
|
const email = entry.email || "unbekannt";
|
|
if (entry.action === "auth.request_access") {
|
|
return `${email} hat einen Login-Link angefordert`;
|
|
}
|
|
if (entry.action === "station.activate.start") {
|
|
return `${email} hat die RMS-Aktivierung gestartet`;
|
|
}
|
|
if (entry.action === "station.activate.done") {
|
|
return `${email} hat die RMS aktiviert`;
|
|
}
|
|
if (entry.action === "station.activate.failed") {
|
|
return `${email} Aktivierung fehlgeschlagen`;
|
|
}
|
|
if (entry.action === "station.deactivate") {
|
|
return `${email} hat die RMS manuell beendet`;
|
|
}
|
|
if (entry.action === "station.deactivate.timeout") {
|
|
return `${email} RMS wurde automatisch wegen Zeitlimit beendet`;
|
|
}
|
|
return `${email} ${entry.action}`;
|
|
}
|
|
|
|
async function saveAuthState() {
|
|
await writeJson(files.auth, runtime.authState);
|
|
}
|
|
|
|
async function savePluginState() {
|
|
await writeJson(files.plugins, runtime.pluginState);
|
|
}
|
|
|
|
async function saveApprovalRequests() {
|
|
await writeJson(files.approvals, runtime.approvalRequests);
|
|
}
|
|
|
|
async function saveSystemState() {
|
|
await writeJson(files.system, runtime.systemState);
|
|
}
|
|
|
|
function pruneRefreshTokens() {
|
|
const now = Date.now();
|
|
runtime.authState.refreshTokens = runtime.authState.refreshTokens.filter((token) => {
|
|
if (token.expiresAtMs < now - 24 * 60 * 60 * 1000) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
async function revokeSessionFamily(sid) {
|
|
for (const token of runtime.authState.refreshTokens) {
|
|
if (token.sid === sid && !token.revokedAt) {
|
|
token.revokedAt = new Date().toISOString();
|
|
}
|
|
}
|
|
await saveAuthState();
|
|
}
|
|
|
|
function getPluginSetting(pluginId, key, fallback = null) {
|
|
const pluginSettings = runtime.pluginState.settings[pluginId] || {};
|
|
if (key in pluginSettings) {
|
|
return pluginSettings[key];
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function validateSettings(schema, value) {
|
|
if (!schema || schema.type !== "object") {
|
|
return { ok: true };
|
|
}
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return { ok: false, message: "settings muss ein Objekt sein" };
|
|
}
|
|
|
|
const properties = schema.properties || {};
|
|
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
|
|
for (const key of required) {
|
|
if (!(key in value)) {
|
|
return { ok: false, message: `Pflichtfeld fehlt: ${key}` };
|
|
}
|
|
}
|
|
|
|
for (const [key, fieldValue] of Object.entries(value)) {
|
|
const fieldSchema = properties[key];
|
|
if (!fieldSchema) {
|
|
if (schema.additionalProperties === false) {
|
|
return { ok: false, message: `Unbekanntes Feld: ${key}` };
|
|
}
|
|
continue;
|
|
}
|
|
const type = fieldSchema.type;
|
|
if (type === "string" && typeof fieldValue !== "string") {
|
|
return { ok: false, message: `${key} muss string sein` };
|
|
}
|
|
if (type === "number" && typeof fieldValue !== "number") {
|
|
return { ok: false, message: `${key} muss number sein` };
|
|
}
|
|
if (type === "integer" && (!Number.isInteger(fieldValue))) {
|
|
return { ok: false, message: `${key} muss integer sein` };
|
|
}
|
|
if (type === "boolean" && typeof fieldValue !== "boolean") {
|
|
return { ok: false, message: `${key} muss boolean sein` };
|
|
}
|
|
if (Array.isArray(fieldSchema.enum) && !fieldSchema.enum.includes(fieldValue)) {
|
|
return { ok: false, message: `${key} hat ungueltigen Wert` };
|
|
}
|
|
if (typeof fieldValue === "number") {
|
|
if (fieldSchema.minimum !== undefined && fieldValue < fieldSchema.minimum) {
|
|
return { ok: false, message: `${key} ist kleiner als minimum` };
|
|
}
|
|
if (fieldSchema.maximum !== undefined && fieldValue > fieldSchema.maximum) {
|
|
return { ok: false, message: `${key} ist groesser als maximum` };
|
|
}
|
|
}
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
function enforceRateLimit(req, res, key, limit, windowMs) {
|
|
const now = Date.now();
|
|
const bucket = runtime.rateBuckets.get(key) || { count: 0, resetAt: now + windowMs };
|
|
if (now > bucket.resetAt) {
|
|
bucket.count = 0;
|
|
bucket.resetAt = now + windowMs;
|
|
}
|
|
bucket.count += 1;
|
|
runtime.rateBuckets.set(key, bucket);
|
|
if (bucket.count <= limit) {
|
|
return true;
|
|
}
|
|
const retryAfterSec = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000));
|
|
res.setHeader("Retry-After", String(retryAfterSec));
|
|
sendError(res, 429, "rate_limit.exceeded", "Zu viele Anfragen", { retryAfterSec });
|
|
return false;
|
|
}
|
|
|
|
function clientIp(req) {
|
|
const forwarded = req.headers["x-forwarded-for"];
|
|
if (typeof forwarded === "string" && forwarded.trim()) {
|
|
return forwarded.split(",")[0].trim();
|
|
}
|
|
return req.socket.remoteAddress || "unknown";
|
|
}
|
|
|
|
function sanitizeUser(user) {
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
role: user.role || "operator",
|
|
status: user.status || "pending_verification",
|
|
accountType: user.accountType || (isPrimaryDomainEmail(user.email) ? "primary-domain" : "external-domain"),
|
|
enabledAuthMethods: Array.isArray(user.enabledAuthMethods) ? user.enabledAuthMethods : [],
|
|
primaryAuthMethod: user.primaryAuthMethod || null,
|
|
preferredLanguage: user.preferredLanguage || "de",
|
|
createdAt: user.createdAt,
|
|
emailVerifiedAt: user.emailVerifiedAt || null,
|
|
approvedAt: user.approvedAt || null,
|
|
deniedAt: user.deniedAt || null
|
|
};
|
|
}
|
|
|
|
function normalizeEmail(value) {
|
|
if (typeof value !== "string") {
|
|
return "";
|
|
}
|
|
return value.trim().toLowerCase();
|
|
}
|
|
|
|
function isValidEmail(email) {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
}
|
|
|
|
function hashPassword(password) {
|
|
const salt = crypto.randomBytes(16).toString("hex");
|
|
const hash = crypto.scryptSync(password, salt, 64).toString("hex");
|
|
return `${salt}:${hash}`;
|
|
}
|
|
|
|
function verifyPassword(password, packed) {
|
|
if (!packed || typeof packed !== "string" || !packed.includes(":")) {
|
|
return false;
|
|
}
|
|
const [salt, expectedHex] = packed.split(":");
|
|
const actualHex = crypto.scryptSync(password, salt, 64).toString("hex");
|
|
const expected = Buffer.from(expectedHex, "hex");
|
|
const actual = Buffer.from(actualHex, "hex");
|
|
if (expected.length !== actual.length) {
|
|
return false;
|
|
}
|
|
return crypto.timingSafeEqual(expected, actual);
|
|
}
|
|
|
|
function sha256(value) {
|
|
return crypto.createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
function base64UrlEncode(value) {
|
|
return Buffer.from(value, "utf8").toString("base64url");
|
|
}
|
|
|
|
function base64UrlDecode(value) {
|
|
return Buffer.from(value, "base64url").toString("utf8");
|
|
}
|
|
|
|
function safeEquals(a, b) {
|
|
const left = Buffer.from(String(a));
|
|
const right = Buffer.from(String(b));
|
|
if (left.length !== right.length) {
|
|
return false;
|
|
}
|
|
return crypto.timingSafeEqual(left, right);
|
|
}
|
|
|
|
function splitCommand(commandString) {
|
|
return commandString.match(/(?:[^\s\"]+|\"[^\"]*\")+/g)?.map((part) => part.replace(/^\"|\"$/g, "")) || [];
|
|
}
|
|
|
|
async function readJson(filePath, fallback) {
|
|
return storage.readJson(filePath, fallback);
|
|
}
|
|
|
|
async function writeJson(filePath, value) {
|
|
await storage.writeJson(filePath, value);
|
|
}
|
|
|
|
async function withLock(action) {
|
|
const run = mutex.then(action, action);
|
|
mutex = run.then(() => undefined, () => undefined);
|
|
return run;
|
|
}
|
|
|
|
async function readJsonBody(req) {
|
|
const chunks = [];
|
|
for await (const chunk of req) {
|
|
chunks.push(chunk);
|
|
}
|
|
if (chunks.length === 0) {
|
|
return {};
|
|
}
|
|
const text = Buffer.concat(chunks).toString("utf8");
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
const err = new Error("invalid json");
|
|
err.code = "INVALID_JSON";
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function sendJson(res, statusCode, payload) {
|
|
const body = JSON.stringify(payload);
|
|
res.writeHead(statusCode, {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
"Cache-Control": "no-store"
|
|
});
|
|
res.end(body);
|
|
}
|
|
|
|
function sendError(res, statusCode, code, message, details = null) {
|
|
return sendJson(res, statusCode, {
|
|
error: {
|
|
code,
|
|
message,
|
|
details
|
|
}
|
|
});
|
|
}
|
|
|
|
async function safePluginHealth(plugin) {
|
|
if (!plugin.instance.health) {
|
|
return "healthy";
|
|
}
|
|
try {
|
|
const result = await plugin.instance.health();
|
|
if (!result || result.ok === false) {
|
|
return "degraded";
|
|
}
|
|
return "healthy";
|
|
} catch {
|
|
return "failing";
|
|
}
|
|
}
|
|
|
|
async function refreshPluginHealth() {
|
|
for (const plugin of runtime.plugins.values()) {
|
|
const next = await safePluginHealth(plugin);
|
|
const pluginId = plugin.manifest.id;
|
|
const prev = runtime.pluginHealth[pluginId] || "unknown";
|
|
runtime.pluginHealth[pluginId] = next;
|
|
if (next !== prev) {
|
|
broadcastEvent("plugin.health.changed", {
|
|
pluginId,
|
|
previous: prev,
|
|
health: next
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async function safeStatus(instance) {
|
|
try {
|
|
return await instance.getStatus();
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async function serveStaticFile(baseDir, req, res, pathname) {
|
|
if (pathname.startsWith("/uploads/")) {
|
|
const uploadsPath = pathname.slice("/uploads".length) || "/";
|
|
return serveFileFromBaseDir(config.brandingUploadsDir, req, res, uploadsPath, { cachePolicy: "no-store" });
|
|
}
|
|
|
|
if (pathname.startsWith("/vswr/images/")) {
|
|
const imagesPath = pathname.slice("/vswr/images".length) || "/";
|
|
return serveFileFromBaseDir(config.vswrImagesDir, req, res, imagesPath, { cachePolicy: "no-store" });
|
|
}
|
|
|
|
const publicDir = path.join(baseDir, "public");
|
|
return serveFileFromBaseDir(publicDir, req, res, pathname, { spaFallback: true, cachePolicy: "revalidate" });
|
|
}
|
|
|
|
async function serveFileFromBaseDir(baseDir, req, res, pathname, options = {}) {
|
|
const spaFallback = Boolean(options.spaFallback);
|
|
const cachePolicy = String(options.cachePolicy || "revalidate").trim().toLowerCase();
|
|
const resolvedBaseDir = path.resolve(baseDir);
|
|
const requestedPath = pathname === "/" ? "/index.html" : pathname;
|
|
const effectivePath = spaFallback && isSpaRoute(pathname) ? "/index.html" : requestedPath;
|
|
|
|
const safePath = path
|
|
.normalize(effectivePath)
|
|
.replace(/^[/\\]+/, "")
|
|
.replace(/^([.][.][/\\])+/, "");
|
|
const fullPath = path.resolve(resolvedBaseDir, safePath);
|
|
|
|
if (fullPath !== resolvedBaseDir && !fullPath.startsWith(resolvedBaseDir + path.sep)) {
|
|
return sendError(res, 403, "path.forbidden", "Unerlaubter Pfad");
|
|
}
|
|
|
|
try {
|
|
const stat = await fsp.stat(fullPath);
|
|
if (stat.isDirectory()) {
|
|
return sendError(res, 404, "file.not_found", "Nicht gefunden");
|
|
}
|
|
const content = await fsp.readFile(fullPath);
|
|
const ext = path.extname(fullPath).toLowerCase();
|
|
const contentType = mimeTypeFor(ext);
|
|
const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs).toString(16)}"`;
|
|
const lastModified = stat.mtime.toUTCString();
|
|
const ifNoneMatch = String(req.headers["if-none-match"] || "").trim();
|
|
if (cachePolicy !== "no-store" && ifNoneMatch && ifNoneMatch === etag) {
|
|
res.writeHead(304, {
|
|
ETag: etag,
|
|
"Last-Modified": lastModified,
|
|
"Cache-Control": cacheControlForStatic(cachePolicy, ext)
|
|
});
|
|
return res.end();
|
|
}
|
|
res.writeHead(200, {
|
|
"Content-Type": contentType,
|
|
"Cache-Control": cacheControlForStatic(cachePolicy, ext),
|
|
ETag: etag,
|
|
"Last-Modified": lastModified
|
|
});
|
|
if (req.method === "HEAD") {
|
|
return res.end();
|
|
}
|
|
return res.end(content);
|
|
} catch {
|
|
return sendError(res, 404, "file.not_found", "Nicht gefunden");
|
|
}
|
|
}
|
|
|
|
function cacheControlForStatic(cachePolicy, ext) {
|
|
if (cachePolicy === "no-store") {
|
|
return "no-store";
|
|
}
|
|
if (ext === ".html" || ext === ".js" || ext === ".css" || ext === ".json") {
|
|
return "no-cache, must-revalidate";
|
|
}
|
|
return "no-cache";
|
|
}
|
|
|
|
function isSpaRoute(pathname) {
|
|
if (!pathname || pathname === "/") {
|
|
return false;
|
|
}
|
|
if (pathname.startsWith("/v1/") || pathname.startsWith("/api/")) {
|
|
return false;
|
|
}
|
|
const ext = path.posix.extname(pathname);
|
|
return ext === "";
|
|
}
|
|
|
|
function mimeTypeFor(ext) {
|
|
if (ext === ".html") return "text/html; charset=utf-8";
|
|
if (ext === ".css") return "text/css; charset=utf-8";
|
|
if (ext === ".js") return "application/javascript; charset=utf-8";
|
|
if (ext === ".json") return "application/json; charset=utf-8";
|
|
if (ext === ".svg") return "image/svg+xml";
|
|
if (ext === ".png") return "image/png";
|
|
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
|
if (ext === ".webp") return "image/webp";
|
|
return "application/octet-stream";
|
|
}
|
|
|
|
function resolveExecMode(explicitValue, lifecycleEvent) {
|
|
const lifecycle = String(lifecycleEvent || "").trim().toLowerCase();
|
|
if (lifecycle === "prod") {
|
|
return "prod";
|
|
}
|
|
if (lifecycle === "dev") {
|
|
return "dev";
|
|
}
|
|
const explicit = String(explicitValue || "").trim().toLowerCase();
|
|
if (explicit === "prod" || explicit === "production") return "prod";
|
|
if (explicit === "dev" || explicit === "development") return "dev";
|
|
return "dev";
|
|
}
|
|
|
|
function loadDotEnv(filePath) {
|
|
if (!fs.existsSync(filePath)) {
|
|
return;
|
|
}
|
|
const raw = fs.readFileSync(filePath, "utf8");
|
|
for (const line of raw.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith("#")) {
|
|
continue;
|
|
}
|
|
const idx = trimmed.indexOf("=");
|
|
if (idx === -1) {
|
|
continue;
|
|
}
|
|
const key = trimmed.slice(0, idx).trim();
|
|
const value = trimmed.slice(idx + 1).trim();
|
|
if (!(key in process.env)) {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
}
|