feat(infra): Docker-compose configuration and environment-specific security guards for Beta deployment to Test environment (#56)
This pull request introduces the dedicated containerized infrastructure and configuration for deploying NexusReader's beta version in the Test environment. ### Summary of Changes 1. **Docker Infrastructure & Secrets**: - **`docker-compose.test.yml`**: Configured dedicated database and auxiliary services (PostgreSQL 17, Qdrant, Neo4j) on isolated, non-standard ports to ensure zero conflict with the existing server configurations. - **`.env.test.template`**: Provided an environment variable template showing required setups, including mandatory database passwords, API keys, and admin custom passwords. - **`.gitignore`**: Excluded local `.env` files to prevent accidental commits of production or staging secrets. 2. **Database Hardening**: - Configured Neo4j with basic authentication (`IDriver` instantiation uses basic auth when credentials are provided in configuration). - Configured PostgreSQL to use mandatory authentication. - Configured the admin seeder (`DbInitializer.cs`) to dynamically use `NEXUS_ADMIN_PASSWORD` from environment variables, falling back to a default password in local Development only. 3. **Feature-Flagged Restrictions**: - **`appsettings.Test.json`**: Implemented `Features:AllowRegistration` and `Features:AllowPasswordReset` flags set to `false`. - **Middleware Enforcement (`Program.cs`)**: Intercepts requests to `/identity/register` and `/identity/forgotPassword` (and their MVC/form variations) and rejects them with a `403 Forbidden` response in restricted environments. - **OAuth Provisioning Guard (`Program.cs`)**: Blocks new account provisioning via Google OAuth callback by checking the `Features:AllowRegistration` configuration, redirecting users to the login page with a descriptive error. - **UI Protection (`Login.razor`, `Register.razor`)**: Conditionally hides registration/password reset links and intercepts manual navigation attempts to `/account/register` by redirecting to login with a warning. --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #56 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #56.
This commit is contained in:
@@ -113,6 +113,85 @@ let svgElement;
|
||||
|
||||
let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeObserver;
|
||||
|
||||
let isMobileMode = false;
|
||||
let activeNodeId = null;
|
||||
|
||||
const getNodeGlyph = d => {
|
||||
if (!d) return 'C';
|
||||
const type = getNodeType(d);
|
||||
const group = getNodeGroup(d);
|
||||
if (type === 'rule') return '§';
|
||||
if (type === 'definition') return 'D';
|
||||
if (type === 'table') return 'T';
|
||||
if (type === 'section') return 'S';
|
||||
if (group === 'bridge') return 'B';
|
||||
if (group === 'current') return '★';
|
||||
return 'C';
|
||||
};
|
||||
|
||||
function updateNodeAppearances() {
|
||||
if (!node) return;
|
||||
|
||||
node.each(function(d) {
|
||||
const g = d3.select(this);
|
||||
const rect = g.select(".node-pill");
|
||||
const text = g.select("text");
|
||||
|
||||
const isCurrent = getNodeGroup(d) === 'current';
|
||||
const isSelected = activeNodeId && d.id === activeNodeId;
|
||||
const showFull = !isMobileMode || isSelected || isCurrent;
|
||||
|
||||
if (showFull) {
|
||||
rect.transition().duration(250)
|
||||
.attr("x", -getPillWidth(d) / 2)
|
||||
.attr("width", getPillWidth(d))
|
||||
.attr("height", 30)
|
||||
.attr("rx", 15)
|
||||
.attr("y", -15);
|
||||
|
||||
text.text(getDisplayLabel(d))
|
||||
.attr("font-size", isCurrent || isSelected ? "0.85rem" : "0.8rem")
|
||||
.attr("font-weight", isCurrent || isSelected ? "600" : "normal");
|
||||
} else {
|
||||
rect.transition().duration(250)
|
||||
.attr("x", -15)
|
||||
.attr("width", 30)
|
||||
.attr("height", 30)
|
||||
.attr("rx", 15)
|
||||
.attr("y", -15);
|
||||
|
||||
text.text(getNodeGlyph(d))
|
||||
.attr("font-size", "0.9rem")
|
||||
.attr("font-weight", "bold");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function setMobileMode(isMobile) {
|
||||
isMobileMode = isMobile;
|
||||
if (!simulation) return;
|
||||
|
||||
if (isMobile) {
|
||||
simulation.force("charge", d3.forceManyBody().strength(-60));
|
||||
simulation.force("link").distance(180);
|
||||
simulation.force("collide", d3.forceCollide().radius(d => {
|
||||
const isCurrent = getNodeGroup(d) === 'current';
|
||||
const isSelected = activeNodeId && d.id === activeNodeId;
|
||||
if (isCurrent || isSelected) {
|
||||
return (getPillWidth(d) / 2) + 15;
|
||||
}
|
||||
return 20;
|
||||
}));
|
||||
} else {
|
||||
simulation.force("charge", d3.forceManyBody().strength(-400));
|
||||
simulation.force("link").distance(120);
|
||||
simulation.force("collide", d3.forceCollide().radius(d => (getPillWidth(d) / 2) + 20));
|
||||
}
|
||||
|
||||
updateNodeAppearances();
|
||||
simulation.alpha(0.3).restart();
|
||||
}
|
||||
|
||||
export function mount(containerId, data, dotNetHelper) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
@@ -121,6 +200,9 @@ export function mount(containerId, data, dotNetHelper) {
|
||||
width = container.clientWidth || 400;
|
||||
height = container.clientHeight || 400;
|
||||
|
||||
// Clean up any existing SVG to prevent duplicates
|
||||
container.querySelectorAll("svg").forEach(el => el.remove());
|
||||
|
||||
// Create SVG
|
||||
svgElement = d3.select(container).append("svg")
|
||||
.attr("viewBox", [0, 0, width, height])
|
||||
@@ -204,11 +286,21 @@ export function mount(containerId, data, dotNetHelper) {
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
isMobileMode = window.innerWidth < 768;
|
||||
|
||||
simulation = d3.forceSimulation()
|
||||
.force("link", d3.forceLink().id(d => d.id).distance(120))
|
||||
.force("charge", d3.forceManyBody().strength(-400))
|
||||
.force("link", d3.forceLink().id(d => d.id).distance(isMobileMode ? 180 : 120))
|
||||
.force("charge", d3.forceManyBody().strength(isMobileMode ? -60 : -400))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force("collide", d3.forceCollide().radius(d => (getPillWidth(d) / 2) + 20));
|
||||
.force("collide", d3.forceCollide().radius(d => {
|
||||
if (isMobileMode) {
|
||||
const isCurrent = getNodeGroup(d) === 'current';
|
||||
const isSelected = activeNodeId && d.id === activeNodeId;
|
||||
if (isCurrent || isSelected) return (getPillWidth(d) / 2) + 15;
|
||||
return 20;
|
||||
}
|
||||
return (getPillWidth(d) / 2) + 20;
|
||||
}));
|
||||
|
||||
simulation.on("tick", () => {
|
||||
if (link) {
|
||||
@@ -222,6 +314,8 @@ export function mount(containerId, data, dotNetHelper) {
|
||||
|
||||
if (node) {
|
||||
node.attr("transform", d => {
|
||||
if (d.x === undefined || isNaN(d.x) || !isFinite(d.x)) d.x = width / 2;
|
||||
if (d.y === undefined || isNaN(d.y) || !isFinite(d.y)) d.y = height / 2;
|
||||
// Keep within bounds with padding
|
||||
const pillWidth = getPillWidth(d);
|
||||
const halfWidth = pillWidth / 2;
|
||||
@@ -252,10 +346,12 @@ export function updateData(data) {
|
||||
// Keep existing node positions if they match by ID
|
||||
const oldNodes = new Map(simulation.nodes().map(d => [d.id, d]));
|
||||
data.nodes.forEach(d => {
|
||||
if (d.x !== undefined && (!isFinite(d.x) || isNaN(d.x))) d.x = undefined;
|
||||
if (d.y !== undefined && (!isFinite(d.y) || isNaN(d.y))) d.y = undefined;
|
||||
if (oldNodes.has(d.id)) {
|
||||
const old = oldNodes.get(d.id);
|
||||
d.x = old.x;
|
||||
d.y = old.y;
|
||||
if (old.x !== undefined && isFinite(old.x) && !isNaN(old.x)) d.x = old.x;
|
||||
if (old.y !== undefined && isFinite(old.y) && !isNaN(old.y)) d.y = old.y;
|
||||
d.vx = old.vx;
|
||||
d.vy = old.vy;
|
||||
}
|
||||
@@ -317,22 +413,14 @@ export function updateData(data) {
|
||||
|
||||
g.append("rect")
|
||||
.attr("class", "node-pill")
|
||||
.attr("x", d => -getPillWidth(d) / 2)
|
||||
.attr("y", -15)
|
||||
.attr("width", d => getPillWidth(d))
|
||||
.attr("height", 30)
|
||||
.attr("rx", 15)
|
||||
.attr("fill", "rgba(20, 20, 20, 0.95)")
|
||||
.attr("stroke", d => getCategoryStyle(d).color)
|
||||
.attr("stroke-width", d => getNodeGroup(d) === 'current' ? 2 : 1.2);
|
||||
|
||||
g.append("text")
|
||||
.text(d => getDisplayLabel(d))
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("y", 5)
|
||||
.attr("fill", d => getCategoryStyle(d).textColor)
|
||||
.attr("font-size", "0.8rem")
|
||||
.attr("font-weight", d => getNodeGroup(d) === 'current' ? '600' : 'normal');
|
||||
.attr("fill", d => getCategoryStyle(d).textColor);
|
||||
|
||||
g.append("title")
|
||||
.text(d => d.description ? `${d.label}\n\n${d.description}` : d.label);
|
||||
@@ -345,6 +433,8 @@ export function updateData(data) {
|
||||
exit => exit.transition().duration(500).style("opacity", 0).remove()
|
||||
);
|
||||
|
||||
updateNodeAppearances();
|
||||
|
||||
simulation.nodes(data.nodes);
|
||||
simulation.force("link").links(validLinks);
|
||||
simulation.alpha(0.5).restart();
|
||||
@@ -377,6 +467,7 @@ function drag(simulation) {
|
||||
export function setActiveNode(nodeId) {
|
||||
if (!svgElement || !node) return;
|
||||
|
||||
activeNodeId = nodeId;
|
||||
// Safety check: ensure we only target the first occurrence if IDs are duplicated
|
||||
const targetNode = node.filter(d => d.id === nodeId);
|
||||
if (targetNode.empty()) {
|
||||
@@ -387,6 +478,7 @@ export function setActiveNode(nodeId) {
|
||||
|
||||
const firstMatch = targetNode.filter((d, i) => i === 0);
|
||||
const d = firstMatch.datum();
|
||||
if (!d || d.x === undefined || d.y === undefined || isNaN(d.x) || !isFinite(d.x) || isNaN(d.y) || !isFinite(d.y)) return;
|
||||
|
||||
// Reset all active classes
|
||||
rootGroup.selectAll(".node-pill").classed("nexus-node-active", false);
|
||||
@@ -399,6 +491,20 @@ export function setActiveNode(nodeId) {
|
||||
// Dim others (only exact matches for nodeId will be fully opaque)
|
||||
dimNodes(nodeId);
|
||||
|
||||
// Dynamic collision update if in mobile mode to expand active node
|
||||
if (isMobileMode && simulation) {
|
||||
simulation.force("collide", d3.forceCollide().radius(d => {
|
||||
const isCurrent = getNodeGroup(d) === 'current';
|
||||
const isSelected = activeNodeId && d.id === activeNodeId;
|
||||
if (isCurrent || isSelected) {
|
||||
return (getPillWidth(d) / 2) + 15;
|
||||
}
|
||||
return 20;
|
||||
}));
|
||||
}
|
||||
|
||||
updateNodeAppearances();
|
||||
|
||||
// Smooth transition to the first matching node
|
||||
svgElement.transition().duration(1000).call(
|
||||
zoomBehavior.transform,
|
||||
@@ -441,12 +547,25 @@ export function handleResize(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container || !svgElement || !simulation) return;
|
||||
|
||||
width = container.clientWidth;
|
||||
height = container.clientHeight;
|
||||
const newWidth = container.clientWidth;
|
||||
const newHeight = container.clientHeight;
|
||||
|
||||
// If container is hidden (size is 0), skip resize to avoid collapsing coordinates to (0,0) or NaN
|
||||
if (newWidth <= 0 || newHeight <= 0) return;
|
||||
|
||||
width = newWidth;
|
||||
height = newHeight;
|
||||
|
||||
svgElement.attr("viewBox", [0, 0, width, height]);
|
||||
simulation.force("center", d3.forceCenter(width / 2, height / 2));
|
||||
simulation.alpha(0.3).restart();
|
||||
|
||||
const prevMobileMode = isMobileMode;
|
||||
isMobileMode = window.innerWidth < 768;
|
||||
if (isMobileMode !== prevMobileMode) {
|
||||
setMobileMode(isMobileMode);
|
||||
} else {
|
||||
simulation.alpha(0.3).restart();
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollToNode(id) {
|
||||
@@ -480,21 +599,26 @@ export function zoomReset() {
|
||||
|
||||
export function zoomToFit() {
|
||||
if (!node || node.empty() || !svgElement || !zoomBehavior) return;
|
||||
if (width <= 0 || height <= 0 || isNaN(width) || isNaN(height)) return;
|
||||
|
||||
// Get the actual bounding box of the nodes
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
node.each(d => {
|
||||
const pw = getPillWidth(d) / 2;
|
||||
minX = Math.min(minX, d.x - pw);
|
||||
maxX = Math.max(maxX, d.x + pw);
|
||||
minY = Math.min(minY, d.y - 15);
|
||||
maxY = Math.max(maxY, d.y + 15);
|
||||
if (d && d.x !== undefined && d.y !== undefined && isFinite(d.x) && isFinite(d.y)) {
|
||||
const pw = getPillWidth(d) / 2;
|
||||
minX = Math.min(minX, d.x - pw);
|
||||
maxX = Math.max(maxX, d.x + pw);
|
||||
minY = Math.min(minY, d.y - 15);
|
||||
maxY = Math.max(maxY, d.y + 15);
|
||||
}
|
||||
});
|
||||
|
||||
if (minX === Infinity) return;
|
||||
if (minX === Infinity || maxX === minX || maxY === minY) return;
|
||||
|
||||
const graphWidth = maxX - minX;
|
||||
const graphHeight = maxY - minY;
|
||||
if (graphWidth <= 0 || graphHeight <= 0 || isNaN(graphWidth) || isNaN(graphHeight)) return;
|
||||
|
||||
const midX = (minX + maxX) / 2;
|
||||
const midY = (minY + maxY) / 2;
|
||||
|
||||
@@ -505,6 +629,8 @@ export function zoomToFit() {
|
||||
1.2 // Max scale
|
||||
);
|
||||
|
||||
if (isNaN(scale) || !isFinite(scale) || scale <= 0) return;
|
||||
|
||||
svgElement.transition().duration(750).call(
|
||||
zoomBehavior.transform,
|
||||
d3.zoomIdentity
|
||||
|
||||
Reference in New Issue
Block a user