diff --git a/server/dev-server.mjs b/server/dev-server.mjs index b61b134..b03c031 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -405,6 +405,40 @@ app.get("/api/apps", (req, res) => { res.json({ apps: getAppsForUser(sessionAccess.runtimeContext.groups) }); }); +app.get("/api/services/:serviceSlug/optional-launch", (req, res) => { + const serviceSlug = sanitizeServiceSlug(req.params.serviceSlug); + const fallbackPath = req.query.fallbackTo || req.query.returnTo || "/"; + const fallbackUrl = buildOptionalServiceFallbackUrl(serviceSlug, null, fallbackPath); + const session = getCurrentSession(req); + + if (!session) { + setNoStore(res); + res.redirect(fallbackUrl); + return; + } + + const sessionAccess = getSessionAccessState(session); + + if (!sessionAccess.ok) { + expireSession(res, session); + setNoStore(res); + res.redirect(fallbackUrl); + return; + } + + const runtimeContext = sessionAccess.runtimeContext; + const app = getAppsForUser(runtimeContext.groups).find((candidate) => candidate.slug === serviceSlug); + const appFallbackUrl = buildOptionalServiceFallbackUrl(serviceSlug, app, fallbackPath); + + if (!app || !app.hasAccess || app.status !== "active") { + setNoStore(res); + res.redirect(appFallbackUrl); + return; + } + + redirectToServiceLaunchTarget(res, serviceSlug, runtimeContext, app, req.query.next_path || req.query.returnTo || "/"); +}); + app.get("/api/services/:serviceSlug/launch", requireSession, (req, res) => { const serviceSlug = sanitizeServiceSlug(req.params.serviceSlug); const runtimeContext = getRuntimeSessionContext(req.nodedcSession); @@ -415,53 +449,7 @@ app.get("/api/services/:serviceSlug/launch", requireSession, (req, res) => { return; } - if (isEngineServiceSlug(serviceSlug)) { - const handoffToken = createServiceHandoff("nodedc", runtimeContext.user); - const targetUrl = new URL("/auth/nodedc/handoff/", getEngineLaunchTargetUrl(app)); - const nextPath = sanitizeReturnTo(req.query.next_path || req.query.returnTo || "/"); - - targetUrl.searchParams.set("token", handoffToken); - - if (nextPath && nextPath !== "/") { - targetUrl.searchParams.set("next_path", nextPath); - } - - res.redirect(targetUrl.toString()); - return; - } - - if (isBimViewerServiceSlug(serviceSlug)) { - const handoffToken = createServiceHandoff(serviceSlug, runtimeContext.user); - const targetUrl = new URL("/auth/nodedc/handoff", getBimViewerBaseUrl(app)); - const nextPath = sanitizeReturnTo(req.query.next_path || req.query.returnTo || "/"); - - targetUrl.searchParams.set("token", handoffToken); - - if (nextPath && nextPath !== "/") { - targetUrl.searchParams.set("next_path", nextPath); - } - - res.redirect(targetUrl.toString()); - return; - } - - if (serviceSlug !== "task-manager") { - res.redirect(app.openUrl || app.url || "/"); - return; - } - - const handoffToken = createServiceHandoff(serviceSlug, runtimeContext.user); - const taskBaseUrl = getTaskBaseUrl(); - const targetUrl = new URL("/auth/nodedc/handoff/", taskBaseUrl); - const nextPath = sanitizeReturnTo(req.query.next_path || req.query.returnTo || "/"); - - targetUrl.searchParams.set("token", handoffToken); - - if (nextPath && nextPath !== "/") { - targetUrl.searchParams.set("next_path", nextPath); - } - - res.redirect(targetUrl.toString()); + redirectToServiceLaunchTarget(res, serviceSlug, runtimeContext, app, req.query.next_path || req.query.returnTo || "/"); }); app.post("/api/internal/handoff/consume", (req, res) => { @@ -3126,6 +3114,88 @@ function getBimViewerBaseUrl(app = null) { ).replace(/\/$/, ""); } +function findServiceCatalogAppBySlug(serviceSlug) { + const slug = sanitizeServiceSlug(serviceSlug); + + try { + const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC optional service fallback" }); + return snapshot.data.services.find((candidate) => sanitizeServiceSlug(candidate.slug) === slug) || null; + } catch { + return null; + } +} + +function buildOptionalServiceFallbackUrl(serviceSlug, app, fallbackPath) { + const path = sanitizeReturnTo(fallbackPath || "/"); + const serviceApp = app || findServiceCatalogAppBySlug(serviceSlug); + let baseUrl = config.appBaseUrl; + + if (isBimViewerServiceSlug(serviceSlug)) { + baseUrl = getBimViewerBaseUrl(serviceApp); + } else if (isEngineServiceSlug(serviceSlug)) { + baseUrl = getEngineLaunchTargetUrl(serviceApp); + } else if (serviceSlug === "task-manager") { + baseUrl = getTaskBaseUrl(); + } else if (serviceApp?.openUrl || serviceApp?.url) { + baseUrl = serviceApp.openUrl || serviceApp.url; + } + + try { + return new URL(path, String(baseUrl || config.appBaseUrl).replace(/\/$/, "")).toString(); + } catch { + return new URL("/", config.appBaseUrl).toString(); + } +} + +function redirectToServiceLaunchTarget(res, serviceSlug, runtimeContext, app, returnTo = "/") { + if (isEngineServiceSlug(serviceSlug)) { + const handoffToken = createServiceHandoff("nodedc", runtimeContext.user); + const targetUrl = new URL("/auth/nodedc/handoff/", getEngineLaunchTargetUrl(app)); + const nextPath = sanitizeReturnTo(returnTo); + + targetUrl.searchParams.set("token", handoffToken); + + if (nextPath && nextPath !== "/") { + targetUrl.searchParams.set("next_path", nextPath); + } + + res.redirect(targetUrl.toString()); + return; + } + + if (isBimViewerServiceSlug(serviceSlug)) { + const handoffToken = createServiceHandoff(serviceSlug, runtimeContext.user); + const targetUrl = new URL("/auth/nodedc/handoff", getBimViewerBaseUrl(app)); + const nextPath = sanitizeReturnTo(returnTo); + + targetUrl.searchParams.set("token", handoffToken); + + if (nextPath && nextPath !== "/") { + targetUrl.searchParams.set("next_path", nextPath); + } + + res.redirect(targetUrl.toString()); + return; + } + + if (serviceSlug !== "task-manager") { + res.redirect(app.openUrl || app.url || "/"); + return; + } + + const handoffToken = createServiceHandoff(serviceSlug, runtimeContext.user); + const targetUrl = new URL("/auth/nodedc/handoff/", getTaskBaseUrl()); + const nextPath = sanitizeReturnTo(returnTo); + + targetUrl.searchParams.set("token", handoffToken); + + if (nextPath && nextPath !== "/") { + targetUrl.searchParams.set("next_path", nextPath); + } + + res.redirect(targetUrl.toString()); +} + function getEnginePublicBaseUrl() { return String( process.env.NODEDC_ENGINE_PUBLIC_URL ||