diff --git a/README.md b/README.md index 689112d2..1677e441 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,10 @@ # zander-web The web component of the Zander project that contains database, API and website. -Documentation: [https://modularsoft.org/docs/products/zander](https://modularsoft.org/docs/products/zander) \ No newline at end of file +Documentation: [https://modularsoft.org/docs/products/zander](https://modularsoft.org/docs/products/zander) + +## Tebex bridge integration + +The `/api/tebex/webhook` endpoint accepts Tebex purchase webhooks and queues bridge tasks for any packages configured in `tebex.json`. Update the mapping file with your package IDs, the commands or rank assignments to trigger, and (optionally) override the target slug or priority per action. + +Secure the webhook by setting the `TEBEX_WEBHOOK_SECRET` environment variable (or the legacy `tebexWebhookSecret`). Requests must include the matching token via an `Authorization`, `X-Tebex-Secret`, or `X-Webhook-Secret` header. diff --git a/api/routes/bridge.js b/api/routes/bridge.js index a7b196c8..b4401d33 100644 --- a/api/routes/bridge.js +++ b/api/routes/bridge.js @@ -6,8 +6,10 @@ const ROUTINE_TABLE = "executorRoutines"; const ROUTINE_STEPS_TABLE = "executorRoutineSteps"; const VALID_STATUSES = ["pending", "processing", "completed", "failed"]; +const ROUTINE_CONFIG_KEY = "_routineConfig"; +const DEFAULT_PLAYER_METADATA_KEY = "player"; -function normalizeCommand(command) { +export function normalizeCommand(command) { if (typeof command !== "string") { return ""; } @@ -15,7 +17,7 @@ function normalizeCommand(command) { return command.trim().replace(/^\/+/, "").trim(); } -function toMetadataObject(value) { +export function toMetadataObject(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } @@ -23,7 +25,7 @@ function toMetadataObject(value) { return value; } -function mergeMetadata(...sources) { +export function mergeMetadata(...sources) { const merged = {}; let hasEntries = false; @@ -46,7 +48,7 @@ function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function applyMetadataPlaceholders(command, ...metadataSources) { +export function applyMetadataPlaceholders(command, ...metadataSources) { let resolved = typeof command === "string" ? command : ""; const mergedMetadata = mergeMetadata(...metadataSources); @@ -87,6 +89,61 @@ function safeJsonParse(value) { } } +function readRoutineConfig(metadata) { + if (!metadata || typeof metadata !== "object") { + return {}; + } + + const config = metadata[ROUTINE_CONFIG_KEY]; + if (!config || typeof config !== "object") { + return {}; + } + + return { ...config }; +} + +function setRoutineConfig(metadata, updates = {}, options = {}) { + if (!metadata || typeof metadata !== "object") { + return; + } + + const { overwrite = false } = options; + const baseConfig = overwrite ? {} : readRoutineConfig(metadata); + metadata[ROUTINE_CONFIG_KEY] = { ...baseConfig, ...updates }; +} + +export function normalizeRankAction(action) { + if (typeof action !== "string") { + return "assign"; + } + + return action.toLowerCase() === "remove" ? "remove" : "assign"; +} + +export function normalizePlayerMetadataKey(value) { + if (typeof value !== "string") { + return DEFAULT_PLAYER_METADATA_KEY; + } + + const trimmed = value.trim(); + return trimmed || DEFAULT_PLAYER_METADATA_KEY; +} + +export function buildRankCommand(rankSlug, rankAction, playerMetadataKey) { + if (!rankSlug) { + return ""; + } + + const action = normalizeRankAction(rankAction); + const playerKey = normalizePlayerMetadataKey(playerMetadataKey); + + if (action === "remove") { + return `lp user {{${playerKey}}} parent remove ${rankSlug}`; + } + + return `lp user {{${playerKey}}} parent add ${rankSlug}`; +} + export default function bridgeApiRoute(app, config, db, features, lang) { function query(sql, params = []) { return new Promise((resolve, reject) => { @@ -238,19 +295,56 @@ export default function bridgeApiRoute(app, config, db, features, lang) { throw new Error(`Task payload at index ${index} must be an object`); } - if (!task.command) { - throw new Error(`Task payload at index ${index} is missing 'command'`); - } - const taskSlug = task.slug || task.target || inlineSlug; if (!taskSlug) { throw new Error(`Task payload at index ${index} is missing 'slug'`); } - const stepMetadata = toMetadataObject(task.metadata); + const baseMetadata = toMetadataObject(task.metadata); + const stepMetadata = baseMetadata ? { ...baseMetadata } : {}; + const config = readRoutineConfig(stepMetadata); + const explicitType = + typeof task.type === "string" ? task.type.toLowerCase() : ""; + const taskType = explicitType || (config.type ? String(config.type).toLowerCase() : "command"); + + let commandTemplate = typeof task.command === "string" ? task.command : ""; + + if (taskType === "rank") { + const rankSlug = (task.rankSlug || config.rankSlug || "").trim(); + if (!rankSlug) { + throw new Error( + `Task payload at index ${index} is missing 'rankSlug' for rank assignment` + ); + } + + const rankAction = normalizeRankAction(task.rankAction || config.rankAction); + const playerKey = normalizePlayerMetadataKey( + task.playerMetadataKey || config.playerMetadataKey + ); + + commandTemplate = buildRankCommand(rankSlug, rankAction, playerKey); + + setRoutineConfig( + stepMetadata, + { + type: "rank", + rankSlug, + rankAction, + playerMetadataKey: playerKey, + }, + { overwrite: true } + ); + } else { + if (!commandTemplate) { + throw new Error(`Task payload at index ${index} is missing 'command'`); + } + + setRoutineConfig(stepMetadata, { type: "command" }); + } + const combinedMetadata = mergeMetadata(rootMetadata, stepMetadata); const resolvedCommand = normalizeCommand( - applyMetadataPlaceholders(task.command, rootMetadata, stepMetadata) + applyMetadataPlaceholders(commandTemplate, rootMetadata, stepMetadata) ); if (!resolvedCommand) { @@ -552,30 +646,69 @@ export default function bridgeApiRoute(app, config, db, features, lang) { throw new Error(`Routine step at index ${index} must be an object`); } - if (!step.command) { - throw new Error(`Routine step at index ${index} is missing 'command'`); - } - const stepSlug = step.slug || step.target; if (!stepSlug) { throw new Error(`Routine step at index ${index} is missing 'slug'`); } const orderValue = Number(step.order ?? index); - const metadataObject = toMetadataObject(step.metadata); - const sanitizedCommand = normalizeCommand(step.command); + const baseMetadata = toMetadataObject(step.metadata); + const metadataObject = baseMetadata ? { ...baseMetadata } : {}; + const config = readRoutineConfig(metadataObject); + const explicitType = + typeof step.type === "string" ? step.type.toLowerCase() : ""; + const stepType = explicitType || (config.type ? String(config.type).toLowerCase() : "command"); + + let sanitizedCommand = ""; + + if (stepType === "rank") { + const rankSlug = (step.rankSlug || config.rankSlug || "").trim(); + if (!rankSlug) { + throw new Error( + `Routine step at index ${index} is missing 'rankSlug' for rank assignment` + ); + } + + const rankAction = normalizeRankAction(step.rankAction || config.rankAction); + const playerKey = normalizePlayerMetadataKey( + step.playerMetadataKey || config.playerMetadataKey + ); + + sanitizedCommand = normalizeCommand( + buildRankCommand(rankSlug, rankAction, playerKey) + ); - if (!sanitizedCommand) { - throw new Error( - `Routine step at index ${index} must include a command after removing leading slashes` + setRoutineConfig( + metadataObject, + { + type: "rank", + rankSlug, + rankAction, + playerMetadataKey: playerKey, + }, + { overwrite: true } ); + } else { + const commandSource = typeof step.command === "string" ? step.command : ""; + sanitizedCommand = normalizeCommand(commandSource); + + if (!sanitizedCommand) { + throw new Error( + `Routine step at index ${index} must include a command after removing leading slashes` + ); + } + + setRoutineConfig(metadataObject, { type: "command" }); } + const metadataPayload = + metadataObject && Object.keys(metadataObject).length ? metadataObject : null; + return { slug: stepSlug, command: sanitizedCommand, order: Number.isFinite(orderValue) ? orderValue : index, - metadata: metadataObject, + metadata: metadataPayload, }; }); diff --git a/api/routes/index.js b/api/routes/index.js index f310ea25..3c737af1 100644 --- a/api/routes/index.js +++ b/api/routes/index.js @@ -8,9 +8,10 @@ import webApiRoute from "./web.js"; import filterApiRoute from "./filter.js"; import rankApiRoute from "./ranks.js"; import reportApiRoute from "./report.js"; -import shopApiRoute from "./shopdirectory.js"; -import vaultApiRoute from "./vault.js"; -import bridgeApiRoute from "./bridge.js"; +import shopApiRoute from "./shopdirectory.js"; +import vaultApiRoute from "./vault.js"; +import bridgeApiRoute from "./bridge.js"; +import tebexApiRoute from "./tebex.js"; export default (app, client, moment, config, db, features, lang) => { announcementApiRoute(app, config, db, features, lang); @@ -23,9 +24,10 @@ export default (app, client, moment, config, db, features, lang) => { webApiRoute(app, config, db, features, lang); rankApiRoute(app, config, db, features, lang); filterApiRoute(app, client, config, db, features, lang); - shopApiRoute(app, config, db, features, lang); - vaultApiRoute(app, config, db, features, lang); - bridgeApiRoute(app, config, db, features, lang); + shopApiRoute(app, config, db, features, lang); + vaultApiRoute(app, config, db, features, lang); + bridgeApiRoute(app, config, db, features, lang); + tebexApiRoute(app, config, db, features, lang); app.get("/api/heartbeat", async function (req, res) { return res.send({ diff --git a/api/routes/tebex.js b/api/routes/tebex.js new file mode 100644 index 00000000..0b40c085 --- /dev/null +++ b/api/routes/tebex.js @@ -0,0 +1,646 @@ +import { createRequire } from "module"; +import { + normalizeCommand, + toMetadataObject, + mergeMetadata, + applyMetadataPlaceholders, + normalizeRankAction, + normalizePlayerMetadataKey, + buildRankCommand, +} from "./bridge.js"; +import { isFeatureEnabled } from "../common.js"; + +const require = createRequire(import.meta.url); + +const TASK_TABLE = "executorTasks"; +const DEFAULT_PRIORITY = 0; +const SECRET_HEADER_CANDIDATES = [ + "x-tebex-secret", + "x-webhook-secret", + "x-authorization", + "authorization", +]; + +function loadConfig() { + try { + const config = require("../../tebex.json"); + if (!config || typeof config !== "object") { + return { packages: [] }; + } + return config; + } catch (error) { + console.warn("[tebex] Unable to load tebex.json configuration", error); + return { packages: [] }; + } +} + +const tebexConfig = loadConfig(); + +function coerceString(value) { + if (value === null || value === undefined) { + return null; + } + + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed ? trimmed : null; + } + + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + + return null; +} + +function buildPackageIndex(config) { + const packages = Array.isArray(config?.packages) ? config.packages : []; + const idMap = new Map(); + const nameMap = new Map(); + + const recordId = (id, entry) => { + const normalized = coerceString(id); + if (!normalized || idMap.has(normalized)) { + return; + } + + idMap.set(normalized, entry); + }; + + const recordName = (name, entry) => { + const normalized = coerceString(name)?.toLowerCase(); + if (!normalized || nameMap.has(normalized)) { + return; + } + + nameMap.set(normalized, entry); + }; + + packages.forEach((entry) => { + if (!entry || typeof entry !== "object") { + return; + } + + recordId(entry.packageId ?? entry.id, entry); + + if (Array.isArray(entry.packageIds)) { + entry.packageIds.forEach((id) => recordId(id, entry)); + } + + if (Array.isArray(entry.aliases)) { + entry.aliases.forEach((alias) => { + if (typeof alias === "number" || (typeof alias === "string" && /\d/.test(alias))) { + recordId(alias, entry); + } + recordName(alias, entry); + }); + } + + recordName(entry.displayName ?? entry.packageName ?? entry.slug, entry); + }); + + return { packages, idMap, nameMap }; +} + +const packageIndex = buildPackageIndex(tebexConfig); +const defaultTargetSlug = coerceString(tebexConfig?.defaultTargetSlug); + +function findPackageConfig(packagePayload) { + if (!packagePayload || typeof packagePayload !== "object") { + return null; + } + + const idCandidates = [ + packagePayload.packageId, + packagePayload.id, + packagePayload.package_id, + packagePayload.product_id, + packagePayload.option_id, + packagePayload.packageID, + ]; + + for (const id of idCandidates) { + const normalized = coerceString(id); + if (normalized && packageIndex.idMap.has(normalized)) { + return packageIndex.idMap.get(normalized); + } + } + + const nameCandidates = [ + packagePayload.name, + packagePayload.packageName, + packagePayload.package_name, + packagePayload.product_name, + packagePayload.displayName, + ]; + + for (const name of nameCandidates) { + const normalized = coerceString(name)?.toLowerCase(); + if (normalized && packageIndex.nameMap.has(normalized)) { + return packageIndex.nameMap.get(normalized); + } + } + + return null; +} + +function collectPlayerSources(payload) { + const sources = []; + const pushCandidate = (candidate) => { + if (!candidate) { + return; + } + + if (typeof candidate === "string") { + sources.push({ username: candidate }); + return; + } + + if (typeof candidate !== "object") { + return; + } + + sources.push(candidate); + + if (candidate.meta && typeof candidate.meta === "object") { + sources.push(candidate.meta); + } + + if (candidate.data && typeof candidate.data === "object") { + sources.push(candidate.data); + } + + if (candidate.account && typeof candidate.account === "object") { + sources.push(candidate.account); + } + }; + + pushCandidate(payload.player); + pushCandidate(payload.customer); + pushCandidate(payload.user); + pushCandidate(payload.buyer); + pushCandidate(payload.checkout); + pushCandidate(payload.meta); + pushCandidate(payload.player?.meta); + pushCandidate(payload.customer?.meta); + pushCandidate(payload.player?.data); + pushCandidate(payload.customer?.data); + + const inlineUsername = coerceString(payload.username ?? payload.playerName ?? payload.ign); + if (inlineUsername) { + sources.push({ username: inlineUsername }); + } + + const inlineUuid = coerceString(payload.uuid ?? payload.playerUuid ?? payload.player_uuid); + if (inlineUuid) { + sources.push({ uuid: inlineUuid }); + } + + return sources; +} + +function pickFromSources(sources, keys) { + for (const source of sources) { + for (const key of keys) { + if (!source || typeof source !== "object") { + continue; + } + + const value = source[key]; + const normalized = coerceString(value); + if (normalized) { + return normalized; + } + } + } + + return null; +} + +function resolvePlayer(payload) { + const sources = collectPlayerSources(payload); + const username = pickFromSources(sources, [ + "username", + "ign", + "player", + "name", + "nickname", + "playerName", + "minecraftUsername", + "inGameName", + ]); + + const uuid = pickFromSources(sources, [ + "uuid", + "playerUuid", + "player_uuid", + "playerUuidFormatted", + "id", + "playerId", + ]); + + const accountId = pickFromSources(sources, [ + "accountId", + "account_id", + "userId", + "user_id", + "customerId", + "customer_id", + ]); + + const email = pickFromSources(sources, ["email", "mail"]); + + return { + username, + uuid, + accountId, + email, + }; +} + +function buildPurchaseMetadata(payload, playerInfo) { + const metadata = {}; + + if (playerInfo.username) { + metadata.player = playerInfo.username; + } + + if (playerInfo.uuid) { + metadata.playerUuid = playerInfo.uuid; + } + + if (playerInfo.accountId) { + metadata.playerAccountId = playerInfo.accountId; + } + + if (playerInfo.email) { + metadata.playerEmail = playerInfo.email; + } + + const purchaseId = coerceString( + payload.transactionId ?? + payload.id ?? + payload.payment_id ?? + payload.purchase_id ?? + payload.reference ?? + payload.transaction + ); + + if (purchaseId) { + metadata.tebexPurchaseId = purchaseId; + } + + const currency = coerceString( + payload.currency ?? + payload.currencyIso ?? + payload.currencyCode ?? + payload.currency_iso ?? + payload.currency_code + ); + + if (currency) { + metadata.tebexCurrency = currency; + } + + return Object.keys(metadata).length ? metadata : null; +} + +function buildPackageMetadata(packagePayload) { + const metadata = {}; + + const packageId = coerceString( + packagePayload.id ?? + packagePayload.packageId ?? + packagePayload.package_id ?? + packagePayload.product_id + ); + + if (packageId) { + metadata.tebexPackageId = packageId; + } + + const packageName = coerceString( + packagePayload.name ?? + packagePayload.packageName ?? + packagePayload.package_name ?? + packagePayload.displayName + ); + + if (packageName) { + metadata.tebexPackageName = packageName; + } + + const variantId = coerceString(packagePayload.variant_id ?? packagePayload.variantId); + if (variantId) { + metadata.tebexVariantId = variantId; + } + + const expiry = + packagePayload.expiry ?? + packagePayload.expires_at ?? + packagePayload.expiry_date ?? + packagePayload.expireDate; + + if (expiry) { + metadata.tebexPackageExpiry = expiry; + } + + const price = packagePayload.price ?? packagePayload.cost ?? packagePayload.amount; + if (price !== null && price !== undefined) { + metadata.tebexPackagePrice = price; + } + + return Object.keys(metadata).length ? metadata : null; +} + +function resolveTargetSlug(action, packageConfig) { + const candidates = [ + action?.target, + action?.slug, + action?.targetSlug, + packageConfig?.target, + packageConfig?.targetSlug, + packageConfig?.slug, + defaultTargetSlug, + ]; + + for (const candidate of candidates) { + const normalized = coerceString(candidate); + if (normalized) { + return normalized; + } + } + + return null; +} + +function resolvePriority(action, packageConfig) { + const priorityCandidates = [action?.priority, packageConfig?.priority]; + for (const candidate of priorityCandidates) { + if (candidate === null || candidate === undefined) { + continue; + } + + const value = Number(candidate); + if (!Number.isNaN(value)) { + return value; + } + } + + return DEFAULT_PRIORITY; +} + +function resolveActionType(action) { + const typeValue = coerceString(action?.type)?.toLowerCase(); + if (typeValue === "rank") { + return "rank"; + } + + if (typeValue === "command") { + return "command"; + } + + if (coerceString(action?.rankSlug)) { + return "rank"; + } + + return "command"; +} + +function buildActionCommand(action, packageConfig) { + const actionType = resolveActionType(action); + + if (actionType === "rank") { + const rankSlug = coerceString(action?.rankSlug ?? packageConfig?.rankSlug); + if (!rankSlug) { + throw new Error(`Rank actions require a rankSlug in the Tebex package config`); + } + + const rankAction = normalizeRankAction(action?.rankAction ?? packageConfig?.rankAction); + const playerKey = normalizePlayerMetadataKey( + action?.playerMetadataKey ?? packageConfig?.playerMetadataKey + ); + + return buildRankCommand(rankSlug, rankAction, playerKey); + } + + const template = coerceString(action?.command ?? packageConfig?.command); + if (!template) { + throw new Error(`Command actions require a command string in the Tebex package config`); + } + + return template; +} + +function normalizeActions(packageConfig) { + if (Array.isArray(packageConfig?.actions) && packageConfig.actions.length > 0) { + return packageConfig.actions; + } + + const fallback = {}; + if (packageConfig.rankSlug) { + fallback.type = "rank"; + } else if (packageConfig.command) { + fallback.type = "command"; + } + + if (!fallback.type) { + return []; + } + + return [fallback]; +} + +function getSecretFromHeaders(req) { + for (const header of SECRET_HEADER_CANDIDATES) { + const value = req.headers?.[header]; + if (!value) { + continue; + } + + if (Array.isArray(value)) { + if (value.length) { + return coerceString(value[0]); + } + continue; + } + + const normalized = coerceString(value); + if (!normalized) { + continue; + } + + if (normalized.toLowerCase().startsWith("bearer ")) { + return coerceString(normalized.slice(7)); + } + + return normalized; + } + + return null; +} + +export default function tebexApiRoute(app, config, db, features, lang) { + const query = (sql, params = []) => { + return new Promise((resolve, reject) => { + db.query(sql, params, (error, results) => { + if (error) { + reject(error); + } else { + resolve(results); + } + }); + }); + }; + + app.post("/api/tebex/webhook", async function (req, res) { + isFeatureEnabled(features.bridge, res, lang); + + if (!features?.bridge) { + return; + } + + if (!tebexConfig || !Array.isArray(packageIndex.packages)) { + return res.send({ + success: false, + message: `Tebex configuration is not available`, + }); + } + + const expectedSecret = coerceString( + process.env.TEBEX_WEBHOOK_SECRET ?? process.env.tebexWebhookSecret + ); + if (expectedSecret) { + const provided = getSecretFromHeaders(req); + if (!provided || provided !== expectedSecret) { + return res.status(401).send({ + success: false, + message: `Invalid Tebex webhook secret`, + }); + } + } + + if (!req.body || typeof req.body !== "object") { + return res.status(400).send({ + success: false, + message: `Webhook payload must be a JSON object`, + }); + } + + const packages = Array.isArray(req.body.packages) + ? req.body.packages + : req.body.package + ? [req.body.package] + : []; + + if (!packages.length) { + return res.send({ + success: true, + message: `No packages included in Tebex payload`, + data: { + queuedTasks: 0, + unmatchedPackages: [], + }, + }); + } + + const playerInfo = resolvePlayer(req.body); + if (!playerInfo.username) { + return res.status(400).send({ + success: false, + message: `Unable to resolve purchaser username from Tebex payload`, + }); + } + + const purchaseMetadata = buildPurchaseMetadata(req.body, playerInfo); + const unmatchedPackages = []; + const queuedTaskIds = []; + + try { + for (const packagePayload of packages) { + const packageConfig = findPackageConfig(packagePayload); + if (!packageConfig) { + unmatchedPackages.push({ + packageId: + coerceString(packagePayload?.id ?? packagePayload?.packageId ?? packagePayload?.package_id) || + null, + packageName: + coerceString( + packagePayload?.name ?? + packagePayload?.packageName ?? + packagePayload?.package_name ?? + packagePayload?.displayName + ) || null, + }); + continue; + } + + const packageMetadata = buildPackageMetadata(packagePayload); + const actions = normalizeActions(packageConfig); + + if (!actions.length) { + continue; + } + + for (const action of actions) { + const targetSlug = resolveTargetSlug(action, packageConfig); + if (!targetSlug) { + throw new Error( + `Unable to determine a target slug for Tebex package '${ + packageConfig.displayName || packageConfig.packageName || packageConfig.packageId + }'` + ); + } + + const metadata = mergeMetadata( + purchaseMetadata, + packageMetadata, + toMetadataObject(packageConfig.metadata), + toMetadataObject(action.metadata) + ); + + const commandTemplate = buildActionCommand(action, packageConfig); + + const resolvedCommand = normalizeCommand( + applyMetadataPlaceholders( + commandTemplate, + purchaseMetadata, + packageMetadata, + packageConfig.metadata, + action.metadata + ) + ); + + if (!resolvedCommand) { + throw new Error(`Resolved command text is empty for Tebex package action`); + } + + const priority = resolvePriority(action, packageConfig); + + const result = await query( + `INSERT INTO ${TASK_TABLE} (slug, command, status, routineSlug, metadata, priority) VALUES (?, ?, 'pending', NULL, ?, ?)`, + [targetSlug, resolvedCommand, metadata ? JSON.stringify(metadata) : null, priority] + ); + + queuedTaskIds.push(result.insertId); + } + } + } catch (error) { + return res.status(500).send({ + success: false, + message: `${error}`, + }); + } + + return res.send({ + success: true, + message: `Queued ${queuedTaskIds.length} bridge task${queuedTaskIds.length === 1 ? "" : "s"} from Tebex webhook`, + data: { + queuedTasks: queuedTaskIds.length, + taskIds: queuedTaskIds, + unmatchedPackages, + player: playerInfo, + }, + }); + }); +} diff --git a/assets/css/dashboard-style.css b/assets/css/dashboard-style.css index 461d7e25..683e0c65 100644 --- a/assets/css/dashboard-style.css +++ b/assets/css/dashboard-style.css @@ -270,10 +270,28 @@ span.badge { .modal-dialog { margin: 30px auto; position: relative; - top: 50%; - transform: translateY(-50%) !important; width: 70%; } +.modal-dialog.modal-dialog-centered { + display: flex; + align-items: center; + min-height: calc(100% - 60px); +} +.modal-dialog.modal-dialog-centered .modal-content { + margin: auto; +} +.modal-dialog.modal-dialog-scrollable { + height: calc(100% - 3rem); + margin: 1.5rem auto; + max-height: calc(100vh - 3rem); +} +.modal-dialog.modal-dialog-scrollable .modal-content { + max-height: 100%; + overflow: hidden; +} +.modal-dialog.modal-dialog-scrollable .modal-body { + overflow-y: auto; +} .modal-header .close { font-size: 14px; margin-right: 15px; @@ -282,6 +300,120 @@ span.badge { .modal-content { border-radius: 3px; } + +.bridge-routine-modal .modal-content { + display: flex; + flex-direction: column; + height: 100%; +} + +.bridge-routine-modal .modal-body { + overflow-y: auto; + padding-bottom: 2rem; +} + +.routine-builder { + background: #f8f9fc; + border: 1px solid #e3e7f1; + border-radius: 1rem; + padding: 1.5rem; +} + +@media (min-width: 992px) { + .routine-builder { + padding: 2rem; + } +} + +.routine-builder-toolbar h6 { + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + font-size: 0.75rem; + color: #5a5f73; +} + +.routine-steps { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.routine-step { + border: 1px solid #e3e7f1; + border-radius: 1rem; +} + +.routine-step .card-body { + padding: 1.5rem; +} + +@media (min-width: 992px) { + .routine-step .card-body { + padding: 2rem; + } +} + +.routine-step-summary { + border-bottom: 1px solid #edf0f7; + padding-bottom: 1.5rem; +} + +.routine-step-actions { + min-width: 140px; + flex-shrink: 0; +} + +@media (max-width: 575.98px) { + .routine-step-summary { + border-bottom: none; + padding-bottom: 0; + } + + .routine-step-actions { + width: 100%; + justify-content: flex-end; + } + + .routine-step-actions .btn { + width: 100%; + } + + .routine-step-section { + margin-top: 1.25rem; + } +} + +.routine-step-badge { + border-radius: 999px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + font-size: 0.7rem; + padding: 0.45rem 0.85rem; +} + +.routine-step-section { + background: #fbfcff; + border: 1px solid #edf0f7; + border-radius: 0.9rem; + padding: 1rem; +} + +@media (min-width: 768px) { + .routine-step-section { + padding: 1.25rem; + } +} + +.routine-step-section .form-text { + color: #6c7288; +} + +.routine-step-error { + border-color: #dc3545 !important; + box-shadow: 0 0 0 0.15rem rgba(220, 53, 69, 0.1); +} .timeline { list-style: none; padding: 0 0 8px; diff --git a/controllers/userController.js b/controllers/userController.js index 83314e5c..04f35b27 100644 --- a/controllers/userController.js +++ b/controllers/userController.js @@ -563,19 +563,43 @@ export async function getUserPermissions(userData = {}) { permissionSet.add(value); }; - const queueRank = (slug, { direct = false } = {}) => { + const normaliseRankSlug = (slug) => { if (!slug) { + return null; + } + + const trimmed = String(slug).trim(); + if (!trimmed) { + return null; + } + + return trimmed.toLowerCase(); + }; + + const canonicalRankMap = new Map(); + + const queueRank = (slug, { direct = false } = {}) => { + const normalisedSlug = normaliseRankSlug(slug); + if (!normalisedSlug) { return; } - if (direct && !seenDirectRanks.has(slug)) { - seenDirectRanks.add(slug); - directRankOrder.push(slug); + const trimmedSlug = String(slug).trim(); + + if (!canonicalRankMap.has(normalisedSlug) || direct) { + canonicalRankMap.set(normalisedSlug, trimmedSlug); + } + + const canonicalSlug = canonicalRankMap.get(normalisedSlug) || trimmedSlug; + + if (direct && !seenDirectRanks.has(normalisedSlug)) { + seenDirectRanks.add(normalisedSlug); + directRankOrder.push(canonicalSlug); } - if (!queuedRankSet.has(slug)) { - queuedRankSet.add(slug); - queuedRanks.push(slug); + if (!queuedRankSet.has(normalisedSlug)) { + queuedRankSet.add(normalisedSlug); + queuedRanks.push(normalisedSlug); } }; @@ -593,7 +617,7 @@ export async function getUserPermissions(userData = {}) { directPermissions.forEach(({ permission }) => pushPermission(permission)); const rankRows = await runQuery( - `SELECT SUBSTRING_INDEX(permission, '.', -1) AS rankSlug + `SELECT SUBSTRING(permission, LOCATE('.', permission) + 1) AS rankSlug FROM ${LUCKPERMS_USER_PERMISSIONS_TABLE} WHERE uuid = UNHEX(?) AND permission LIKE 'group.%' @@ -630,7 +654,9 @@ export async function getUserPermissions(userData = {}) { } while (queuedRanks.length) { - const currentRank = queuedRanks.shift(); + const currentNormalisedRank = queuedRanks.shift(); + const currentRank = + canonicalRankMap.get(currentNormalisedRank) || currentNormalisedRank; const groupPermissions = await runQuery( `SELECT permission @@ -648,7 +674,7 @@ export async function getUserPermissions(userData = {}) { if (permission.startsWith("group.")) { const inherited = permission.substring("group.".length).trim(); - if (inherited && inherited !== currentRank) { + if (inherited) { queueRank(inherited); } return; diff --git a/routes/dashboard/dashboard.js b/routes/dashboard/dashboard.js index d74e1f1b..8d0a37e7 100644 --- a/routes/dashboard/dashboard.js +++ b/routes/dashboard/dashboard.js @@ -83,7 +83,12 @@ export default function dashboardSiteRoute(app, config, features, lang) { app.get("/dashboard/bridge", async function (req, res) { if (!hasPermission("zander.web.bridge", req, res, features)) return; - const [pendingResponse, processingResponse, routineResponse] = await Promise.all([ + const [ + pendingResponse, + processingResponse, + routineResponse, + ranksResponse, + ] = await Promise.all([ fetch(`${process.env.siteAddress}/api/bridge/processor/get?status=pending&limit=100`, { headers: { "x-access-token": process.env.apiKey }, }), @@ -93,12 +98,18 @@ export default function dashboardSiteRoute(app, config, features, lang) { fetch(`${process.env.siteAddress}/api/bridge/routine/get`, { headers: { "x-access-token": process.env.apiKey }, }), + features?.ranks + ? fetch(`${process.env.siteAddress}/api/rank/get`, { + headers: { "x-access-token": process.env.apiKey }, + }) + : Promise.resolve(null), ]); - const [pendingTasks, processingTasks, routines] = await Promise.all([ + const [pendingTasks, processingTasks, routines, ranks] = await Promise.all([ pendingResponse.json(), processingResponse.json(), routineResponse.json(), + ranksResponse ? ranksResponse.json() : Promise.resolve({ success: false }), ]); res.view("dashboard/bridge", { @@ -107,6 +118,7 @@ export default function dashboardSiteRoute(app, config, features, lang) { pendingTasks: pendingTasks, processingTasks: processingTasks, routines: routines, + ranks: ranks, features: features, req: req, globalImage: await getGlobalImage(), diff --git a/tebex.json b/tebex.json new file mode 100644 index 00000000..bf5afa88 --- /dev/null +++ b/tebex.json @@ -0,0 +1,41 @@ +{ + "defaultTargetSlug": "proxy", + "packages": [ + { + "packageId": 4028837, + "displayName": "Diamond Subscription", + "priority": 5, + "metadata": { + "tebexGrant": "subscription" + }, + "actions": [ + { + "type": "rank", + "rankSlug": "diamond", + "rankAction": "assign", + "metadata": { + "tebexPackageType": "subscription" + } + } + ] + }, + { + "packageId": 4028840, + "displayName": "Diamond Permanent", + "priority": 5, + "metadata": { + "tebexGrant": "permanent" + }, + "actions": [ + { + "type": "rank", + "rankSlug": "diamond", + "rankAction": "assign", + "metadata": { + "tebexPackageType": "permanent" + } + } + ] + } + ] +} diff --git a/views/dashboard/bridge.ejs b/views/dashboard/bridge.ejs index 6d154759..937e9abd 100644 --- a/views/dashboard/bridge.ejs +++ b/views/dashboard/bridge.ejs @@ -14,6 +14,7 @@ const pendingList = pendingTasks && pendingTasks.success ? pendingTasks.data : []; const processingList = processingTasks && processingTasks.success ? processingTasks.data : []; const routinesList = routines && routines.success ? routines.data : []; + const ranksDirectory = ranks && ranks.success ? ranks.data : []; %>
@@ -509,7 +510,7 @@