Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7a7915f8b | |||
| 2d51aa9d39 | |||
| 3f949252c1 | |||
| b6d0150d44 | |||
| 55cd816cca | |||
| d67bd1daf8 | |||
| 4b56d3cfc6 | |||
| 88ed4360c0 | |||
| 81918b00cd | |||
| 3b384dc5fb |
@@ -511,6 +511,17 @@ release-docker: ## Build and push release Docker images
|
|||||||
pre-release-docker: ## Build and push pre-release Docker images
|
pre-release-docker: ## Build and push pre-release Docker images
|
||||||
./publish-docker-prerelease.sh
|
./publish-docker-prerelease.sh
|
||||||
|
|
||||||
|
dev-release: ## Build and push custom dev release (usage: make dev-release NAME=issue38)
|
||||||
|
@if [ -z "$(NAME)" ]; then \
|
||||||
|
echo "$(RED)ERROR: NAME parameter is required!$(NC)"; \
|
||||||
|
echo "$(YELLOW)Usage: make dev-release NAME=<custom-name>$(NC)"; \
|
||||||
|
echo "$(YELLOW)Example: make dev-release NAME=issue38$(NC)"; \
|
||||||
|
echo "$(YELLOW) This will create tags like: 0.3.1-dev-issue38$(NC)"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@echo "$(BLUE)Building custom dev release: $(NAME)$(NC)"
|
||||||
|
@./publish-docker-dev.sh $(NAME)
|
||||||
|
|
||||||
#===============================================================================
|
#===============================================================================
|
||||||
# DATABASE
|
# DATABASE
|
||||||
#===============================================================================
|
#===============================================================================
|
||||||
|
|||||||
@@ -120,14 +120,17 @@ docker compose up -d
|
|||||||
|
|
||||||
When running ExcaliDash behind Traefik, Nginx, or another reverse proxy, configure both containers so that API + WebSocket calls resolve correctly:
|
When running ExcaliDash behind Traefik, Nginx, or another reverse proxy, configure both containers so that API + WebSocket calls resolve correctly:
|
||||||
|
|
||||||
- `FRONTEND_URL` (backend) must match the public URL that users hit (e.g. `https://excalidash.example.com`). This controls CORS and Socket.IO origin checks.
|
- `FRONTEND_URL` (backend) must match the public URL that users hit (e.g. `https://excalidash.example.com`). This controls CORS and Socket.IO origin checks. **Supports multiple comma-separated URLs** for accessing from different addresses.
|
||||||
- `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname.
|
- `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yml example
|
# docker-compose.yml example
|
||||||
backend:
|
backend:
|
||||||
environment:
|
environment:
|
||||||
|
# Single URL
|
||||||
- FRONTEND_URL=https://excalidash.example.com
|
- FRONTEND_URL=https://excalidash.example.com
|
||||||
|
# Or multiple URLs (comma-separated) for local + network access
|
||||||
|
# - FRONTEND_URL=http://localhost:6767,http://192.168.1.100:6767,http://nas.local:6767
|
||||||
frontend:
|
frontend:
|
||||||
environment:
|
environment:
|
||||||
# For standard Docker Compose (default)
|
# For standard Docker Compose (default)
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "0.1.8",
|
"version": "0.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "0.1.8",
|
"version": "0.3.2",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "0.3.0",
|
"version": "0.3.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Issue #38: CSRF fails with multiple reverse proxies
|
||||||
|
*
|
||||||
|
* This test demonstrates how trust proxy settings affect CSRF validation
|
||||||
|
* when ExcaliDash is behind multiple proxy layers (e.g., Traefik, Synology NAS)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import {
|
||||||
|
createCsrfToken,
|
||||||
|
validateCsrfToken,
|
||||||
|
getCsrfTokenHeader,
|
||||||
|
} from "../security";
|
||||||
|
|
||||||
|
// mock the getClientId function behavior
|
||||||
|
const getClientIdFromRequest = (req: express.Request): string => {
|
||||||
|
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
||||||
|
const userAgent = req.headers["user-agent"] || "unknown";
|
||||||
|
return `${ip}:${userAgent}`.slice(0, 256);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Issue #38: CSRF with trust proxy settings", () => {
|
||||||
|
let app: express.Application;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("demonstrates the trust proxy issue with multiple proxies", async () => {
|
||||||
|
// ext proxy -> frontend nginx -> backend
|
||||||
|
// X-Forwarded-For: 203.0.113.42 (client), 10.0.0.5 (external proxy), 172.17.0.3 (frontend nginx)
|
||||||
|
|
||||||
|
// With trust proxy: 1 (current setting)
|
||||||
|
const app1 = express();
|
||||||
|
app1.set("trust proxy", 1);
|
||||||
|
app1.use(express.json());
|
||||||
|
|
||||||
|
app1.get("/test-ip", (req, res) => {
|
||||||
|
res.json({
|
||||||
|
ip: req.ip,
|
||||||
|
clientId: getClientIdFromRequest(req),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate request through multiple proxies
|
||||||
|
const response1 = await request(app1)
|
||||||
|
.get("/test-ip")
|
||||||
|
.set("X-Forwarded-For", "203.0.113.42, 10.0.0.5, 172.17.0.3")
|
||||||
|
.set("User-Agent", "Mozilla/5.0 Test");
|
||||||
|
|
||||||
|
// With trust proxy: 1 in supertest (no real socket), Express takes the last IP
|
||||||
|
// In production with a real connection, behavior differs - the key point is it's NOT the client IP
|
||||||
|
expect(response1.body.ip).toBe("172.17.0.3");
|
||||||
|
console.log(
|
||||||
|
"trust proxy: 1 → IP:",
|
||||||
|
response1.body.ip,
|
||||||
|
"(not the real client IP)",
|
||||||
|
);
|
||||||
|
|
||||||
|
// With trust proxy: true
|
||||||
|
const app2 = express();
|
||||||
|
app2.set("trust proxy", true);
|
||||||
|
app2.use(express.json());
|
||||||
|
|
||||||
|
app2.get("/test-ip", (req, res) => {
|
||||||
|
res.json({
|
||||||
|
ip: req.ip,
|
||||||
|
clientId: getClientIdFromRequest(req),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const response2 = await request(app2)
|
||||||
|
.get("/test-ip")
|
||||||
|
.set("X-Forwarded-For", "203.0.113.42, 10.0.0.5, 172.17.0.3")
|
||||||
|
.set("User-Agent", "Mozilla/5.0 Test");
|
||||||
|
|
||||||
|
// With trust proxy: true, Express takes leftmost IP
|
||||||
|
expect(response2.body.ip).toBe("203.0.113.42");
|
||||||
|
console.log(
|
||||||
|
"trust proxy: true → IP:",
|
||||||
|
response2.body.ip,
|
||||||
|
"(real client IP - CORRECT)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("simulates CSRF failure scenario from issue #38", async () => {
|
||||||
|
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
|
||||||
|
|
||||||
|
// Request 1: Fetch CSRF token
|
||||||
|
// X-Forwarded-For shows: client, external-proxy-1, frontend-nginx
|
||||||
|
const clientIp1 = "203.0.113.42";
|
||||||
|
const externalProxyIp1 = "10.0.0.5"; // External proxy IP on first request
|
||||||
|
|
||||||
|
// With trust proxy: 1, Express sees the external proxy IP
|
||||||
|
const clientId1 = `${externalProxyIp1}:${userAgent}`;
|
||||||
|
const token = createCsrfToken(clientId1);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
" X-Forwarded-For:",
|
||||||
|
`${clientIp1}, ${externalProxyIp1}, 172.17.0.3`,
|
||||||
|
);
|
||||||
|
console.log(" Express sees IP:", externalProxyIp1);
|
||||||
|
console.log(" ClientId:", clientId1.slice(0, 50) + "...");
|
||||||
|
|
||||||
|
// Request 2: Try to create drawing with token
|
||||||
|
// External proxy IP might differ slightly
|
||||||
|
const externalProxyIp2 = "10.0.0.6";
|
||||||
|
|
||||||
|
const clientId2 = `${externalProxyIp2}:${userAgent}`;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
" X-Forwarded-For:",
|
||||||
|
`${clientIp1}, ${externalProxyIp2}, 172.17.0.3`,
|
||||||
|
);
|
||||||
|
console.log(" Express sees IP:", externalProxyIp2);
|
||||||
|
console.log(" ClientId:", clientId2.slice(0, 50) + "...");
|
||||||
|
|
||||||
|
// CSRF validation fails because clientId changed
|
||||||
|
const isValid = validateCsrfToken(clientId2, token);
|
||||||
|
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
console.log(" Expected:", clientId1.slice(0, 50) + "...");
|
||||||
|
console.log(" Got:", clientId2.slice(0, 50) + "...");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the fix works with trust proxy: true", async () => {
|
||||||
|
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
|
||||||
|
const realClientIp = "203.0.113.42";
|
||||||
|
|
||||||
|
const clientId1 = `${realClientIp}:${userAgent}`;
|
||||||
|
const token = createCsrfToken(clientId1);
|
||||||
|
|
||||||
|
console.log(" X-Forwarded-For:", `${realClientIp}, 10.0.0.5, 172.17.0.3`);
|
||||||
|
console.log(" Express sees IP:", realClientIp);
|
||||||
|
|
||||||
|
// Request 2: Use token (even if middle proxy IPs differ)
|
||||||
|
const clientId2 = `${realClientIp}:${userAgent}`;
|
||||||
|
|
||||||
|
console.log("Create drawing");
|
||||||
|
console.log("X-Forwarded-For:", `${realClientIp}, 10.0.0.6, 172.17.0.3`);
|
||||||
|
console.log("Express sees IP:", realClientIp, "(same!)");
|
||||||
|
|
||||||
|
const isValid = validateCsrfToken(clientId2, token);
|
||||||
|
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
console.log("\nCSRF Validation: SUCCESS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("demonstrates the Synology NAS scenario from issue #38", async () => {
|
||||||
|
const app = express();
|
||||||
|
app.set("trust proxy", 1);
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
let seenIp: string | undefined;
|
||||||
|
app.get("/test", (req, res) => {
|
||||||
|
seenIp = req.ip;
|
||||||
|
res.json({ ip: req.ip });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client -> Synology (192.168.1.x) -> Docker frontend (192.168.11.x) -> Backend
|
||||||
|
// In supertest without real socket, trust proxy: 1 returns last IP
|
||||||
|
// Key point: it's NOT the real client IP (192.168.0.100)
|
||||||
|
await request(app)
|
||||||
|
.get("/test")
|
||||||
|
.set("X-Forwarded-For", "192.168.0.100, 192.168.1.4, 192.168.11.166");
|
||||||
|
console.log(" With trust proxy: 1, Express sees:", seenIp);
|
||||||
|
expect(seenIp).toBe("192.168.11.166"); // Not the real client IP
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Security hardening tests
|
||||||
|
*
|
||||||
|
* Tests for input validation and sanitization improvements:
|
||||||
|
* - Route parameter ID validation
|
||||||
|
* - Collection name validation/sanitization
|
||||||
|
* - Library items validation
|
||||||
|
* - Socket.io input validation helpers
|
||||||
|
* - Path traversal protection in archive file names
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { sanitizeText } from "../security";
|
||||||
|
|
||||||
|
// Replicate the validation functions from index.ts to test them in isolation
|
||||||
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
const SAFE_ID_REGEX = /^[a-zA-Z0-9_-]{1,128}$/;
|
||||||
|
|
||||||
|
const isValidResourceId = (id: string): boolean => {
|
||||||
|
return UUID_REGEX.test(id) || SAFE_ID_REGEX.test(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Route Parameter ID Validation", () => {
|
||||||
|
it("should accept valid UUID v4", () => {
|
||||||
|
expect(isValidResourceId("550e8400-e29b-41d4-a716-446655440000")).toBe(true);
|
||||||
|
expect(isValidResourceId("6ba7b810-9dad-11d1-80b4-00c04fd430c8")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept safe alphanumeric IDs", () => {
|
||||||
|
expect(isValidResourceId("trash")).toBe(true);
|
||||||
|
expect(isValidResourceId("default")).toBe(true);
|
||||||
|
expect(isValidResourceId("my-collection-123")).toBe(true);
|
||||||
|
expect(isValidResourceId("element_1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject IDs with path traversal", () => {
|
||||||
|
expect(isValidResourceId("../etc/passwd")).toBe(false);
|
||||||
|
expect(isValidResourceId("..\\windows\\system32")).toBe(false);
|
||||||
|
expect(isValidResourceId("foo/bar")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject IDs with SQL injection attempts", () => {
|
||||||
|
expect(isValidResourceId("'; DROP TABLE drawings; --")).toBe(false);
|
||||||
|
expect(isValidResourceId("1 OR 1=1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject IDs with script injection", () => {
|
||||||
|
expect(isValidResourceId("<script>alert(1)</script>")).toBe(false);
|
||||||
|
expect(isValidResourceId('"><img src=x onerror=alert(1)>')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject empty or excessively long IDs", () => {
|
||||||
|
expect(isValidResourceId("")).toBe(false);
|
||||||
|
expect(isValidResourceId("a".repeat(129))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept IDs at maximum length", () => {
|
||||||
|
expect(isValidResourceId("a".repeat(128))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Collection Name Validation", () => {
|
||||||
|
it("should sanitize collection names with HTML", () => {
|
||||||
|
const result = sanitizeText('<script>alert("xss")</script>My Collection', 255);
|
||||||
|
expect(result).not.toContain("<script>");
|
||||||
|
expect(result).toContain("My Collection");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve normal collection names", () => {
|
||||||
|
const result = sanitizeText("My Drawings Collection", 255);
|
||||||
|
expect(result).toBe("My Drawings Collection");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should truncate overly long names", () => {
|
||||||
|
const longName = "A".repeat(300);
|
||||||
|
const result = sanitizeText(longName, 255);
|
||||||
|
expect(result.length).toBeLessThanOrEqual(255);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should strip control characters", () => {
|
||||||
|
const result = sanitizeText("Name\x00With\x07Control\x1FChars", 255);
|
||||||
|
expect(result).not.toContain("\x00");
|
||||||
|
expect(result).not.toContain("\x07");
|
||||||
|
expect(result).not.toContain("\x1F");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Library Items Validation", () => {
|
||||||
|
it("should accept valid item counts", () => {
|
||||||
|
const items = Array.from({ length: 100 }, (_, i) => ({ id: `item-${i}` }));
|
||||||
|
expect(items.length).toBeLessThanOrEqual(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should flag excessive item counts", () => {
|
||||||
|
const items = Array.from({ length: 10001 }, (_, i) => ({ id: `item-${i}` }));
|
||||||
|
expect(items.length).toBeGreaterThan(10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Archive Path Sanitization", () => {
|
||||||
|
const sanitizeArchiveName = (name: string): string => {
|
||||||
|
return name.replace(/[<>:"/\\|?*]/g, "_").replace(/\.\./g, "_");
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should replace path traversal sequences", () => {
|
||||||
|
const result = sanitizeArchiveName("../../etc/passwd");
|
||||||
|
expect(result).not.toContain("..");
|
||||||
|
expect(result).not.toContain("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should replace dangerous characters", () => {
|
||||||
|
const result = sanitizeArchiveName('my<drawing>:name/"test"\\path|file?name*');
|
||||||
|
expect(result).not.toContain("<");
|
||||||
|
expect(result).not.toContain(">");
|
||||||
|
expect(result).not.toContain(":");
|
||||||
|
expect(result).not.toContain('"');
|
||||||
|
expect(result).not.toContain("\\");
|
||||||
|
expect(result).not.toContain("|");
|
||||||
|
expect(result).not.toContain("?");
|
||||||
|
expect(result).not.toContain("*");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve normal names", () => {
|
||||||
|
const result = sanitizeArchiveName("My Drawing 2024");
|
||||||
|
expect(result).toBe("My Drawing 2024");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle double-dot paths", () => {
|
||||||
|
const result = sanitizeArchiveName("..folder../..test..");
|
||||||
|
expect(result).not.toContain("..");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Socket.io Input Validation Helpers", () => {
|
||||||
|
const isValidDrawingId = (id: unknown): id is string =>
|
||||||
|
typeof id === "string" && id.length > 0 && id.length <= 128 && isValidResourceId(id);
|
||||||
|
|
||||||
|
it("should accept valid drawing IDs", () => {
|
||||||
|
expect(isValidDrawingId("550e8400-e29b-41d4-a716-446655440000")).toBe(true);
|
||||||
|
expect(isValidDrawingId("my-drawing-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject non-string inputs", () => {
|
||||||
|
expect(isValidDrawingId(123)).toBe(false);
|
||||||
|
expect(isValidDrawingId(null)).toBe(false);
|
||||||
|
expect(isValidDrawingId(undefined)).toBe(false);
|
||||||
|
expect(isValidDrawingId({})).toBe(false);
|
||||||
|
expect(isValidDrawingId([])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject empty strings", () => {
|
||||||
|
expect(isValidDrawingId("")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject strings with injection attempts", () => {
|
||||||
|
expect(isValidDrawingId("<script>alert(1)</script>")).toBe(false);
|
||||||
|
expect(isValidDrawingId("../../../etc/passwd")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+137
-14
@@ -129,6 +129,25 @@ const initializeUploadDir = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// Trust proxy headers (X-Forwarded-For, X-Real-IP) from nginx
|
||||||
|
// Required for correct client IP detection when running behind a reverse proxy
|
||||||
|
// Fix for issue #38: Use 'true' to handle multiple proxy layers (e.g., Traefik, Synology NAS)
|
||||||
|
// This ensures Express extracts the real client IP from the leftmost X-Forwarded-For value
|
||||||
|
const trustProxyConfig = process.env.TRUST_PROXY || "true";
|
||||||
|
const trustProxyValue = trustProxyConfig === "true"
|
||||||
|
? true
|
||||||
|
: trustProxyConfig === "false"
|
||||||
|
? false
|
||||||
|
: parseInt(trustProxyConfig, 10) || 1;
|
||||||
|
app.set("trust proxy", trustProxyValue);
|
||||||
|
|
||||||
|
if (trustProxyValue === true) {
|
||||||
|
console.log("[config] trust proxy: enabled (handles multiple proxy layers)");
|
||||||
|
} else {
|
||||||
|
console.log(`[config] trust proxy: ${trustProxyValue}`);
|
||||||
|
}
|
||||||
|
|
||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
const io = new Server(httpServer, {
|
const io = new Server(httpServer, {
|
||||||
cors: {
|
cors: {
|
||||||
@@ -216,10 +235,15 @@ const upload = multer({
|
|||||||
files: 1,
|
files: 1,
|
||||||
},
|
},
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
|
// Reject filenames with path traversal characters
|
||||||
|
const safeName = path.basename(file.originalname);
|
||||||
|
if (safeName !== file.originalname || /[/\\]/.test(file.originalname)) {
|
||||||
|
return cb(new Error("Invalid filename"));
|
||||||
|
}
|
||||||
if (file.fieldname === "db") {
|
if (file.fieldname === "db") {
|
||||||
const isSqliteDb =
|
const isSqliteDb =
|
||||||
file.originalname.endsWith(".db") ||
|
safeName.endsWith(".db") ||
|
||||||
file.originalname.endsWith(".sqlite");
|
safeName.endsWith(".sqlite");
|
||||||
if (!isSqliteDb) {
|
if (!isSqliteDb) {
|
||||||
return cb(new Error("Only .db or .sqlite files are allowed"));
|
return cb(new Error("Only .db or .sqlite files are allowed"));
|
||||||
}
|
}
|
||||||
@@ -263,6 +287,7 @@ app.use((req, res, next) => {
|
|||||||
"Permissions-Policy",
|
"Permissions-Policy",
|
||||||
"geolocation=(), microphone=(), camera=()"
|
"geolocation=(), microphone=(), camera=()"
|
||||||
);
|
);
|
||||||
|
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||||
|
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
"Content-Security-Policy",
|
"Content-Security-Policy",
|
||||||
@@ -272,7 +297,9 @@ app.use((req, res, next) => {
|
|||||||
"font-src 'self' https://fonts.gstatic.com; " +
|
"font-src 'self' https://fonts.gstatic.com; " +
|
||||||
"img-src 'self' data: blob: https:; " +
|
"img-src 'self' data: blob: https:; " +
|
||||||
"connect-src 'self' ws: wss:; " +
|
"connect-src 'self' ws: wss:; " +
|
||||||
"frame-ancestors 'none';"
|
"frame-ancestors 'none'; " +
|
||||||
|
"base-uri 'self'; " +
|
||||||
|
"form-action 'self';"
|
||||||
);
|
);
|
||||||
|
|
||||||
next();
|
next();
|
||||||
@@ -324,9 +351,24 @@ app.use((req, res, next) => {
|
|||||||
const getClientId = (req: express.Request): string => {
|
const getClientId = (req: express.Request): string => {
|
||||||
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
||||||
const userAgent = req.headers["user-agent"] || "unknown";
|
const userAgent = req.headers["user-agent"] || "unknown";
|
||||||
// Create a simple hash for client identification
|
const clientId = `${ip}:${userAgent}`.slice(0, 256);
|
||||||
// In production, you might use a session ID instead
|
|
||||||
return `${ip}:${userAgent}`.slice(0, 256);
|
// Debug logging for CSRF troubleshooting (issue #38)
|
||||||
|
if (process.env.DEBUG_CSRF === "true") {
|
||||||
|
console.log("[CSRF DEBUG] getClientId", {
|
||||||
|
method: req.method,
|
||||||
|
path: req.path,
|
||||||
|
ip,
|
||||||
|
remoteAddress: req.connection.remoteAddress,
|
||||||
|
"x-forwarded-for": req.headers["x-forwarded-for"],
|
||||||
|
"x-real-ip": req.headers["x-real-ip"],
|
||||||
|
userAgent: userAgent.slice(0, 100),
|
||||||
|
clientIdPreview: clientId.slice(0, 60) + "...",
|
||||||
|
trustProxySetting: req.app.get("trust proxy"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientId;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Rate limiter specifically for CSRF token generation to prevent store exhaustion
|
// Rate limiter specifically for CSRF token generation to prevent store exhaustion
|
||||||
@@ -441,6 +483,28 @@ const csrfProtectionMiddleware = (
|
|||||||
// Apply CSRF protection to all routes
|
// Apply CSRF protection to all routes
|
||||||
app.use(csrfProtectionMiddleware);
|
app.use(csrfProtectionMiddleware);
|
||||||
|
|
||||||
|
// Validate route parameter IDs to prevent injection and ensure expected format
|
||||||
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
const SAFE_ID_REGEX = /^[a-zA-Z0-9_-]{1,128}$/;
|
||||||
|
|
||||||
|
const isValidResourceId = (id: string): boolean => {
|
||||||
|
return UUID_REGEX.test(id) || SAFE_ID_REGEX.test(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateIdParam = (
|
||||||
|
req: express.Request,
|
||||||
|
res: express.Response,
|
||||||
|
next: express.NextFunction
|
||||||
|
) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
if (id && !isValidResourceId(id)) {
|
||||||
|
return res.status(400).json({ error: "Invalid resource ID format" });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
app.param("id", validateIdParam);
|
||||||
|
|
||||||
const filesFieldSchema = z
|
const filesFieldSchema = z
|
||||||
.union([z.record(z.string(), z.any()), z.null()])
|
.union([z.record(z.string(), z.any()), z.null()])
|
||||||
.optional()
|
.optional()
|
||||||
@@ -630,6 +694,12 @@ interface User {
|
|||||||
|
|
||||||
const roomUsers = new Map<string, User[]>();
|
const roomUsers = new Map<string, User[]>();
|
||||||
|
|
||||||
|
const isValidSocketId = (id: unknown): id is string =>
|
||||||
|
typeof id === "string" && id.length > 0 && id.length <= 128 && SAFE_ID_REGEX.test(id);
|
||||||
|
|
||||||
|
const isValidDrawingId = (id: unknown): id is string =>
|
||||||
|
typeof id === "string" && id.length > 0 && id.length <= 128 && isValidResourceId(id);
|
||||||
|
|
||||||
io.on("connection", (socket) => {
|
io.on("connection", (socket) => {
|
||||||
socket.on(
|
socket.on(
|
||||||
"join-room",
|
"join-room",
|
||||||
@@ -640,10 +710,16 @@ io.on("connection", (socket) => {
|
|||||||
drawingId: string;
|
drawingId: string;
|
||||||
user: Omit<User, "socketId" | "isActive">;
|
user: Omit<User, "socketId" | "isActive">;
|
||||||
}) => {
|
}) => {
|
||||||
|
if (!isValidDrawingId(drawingId)) return;
|
||||||
|
if (!user || !isValidSocketId(user.id)) return;
|
||||||
|
const safeName = sanitizeText(typeof user.name === "string" ? user.name : "", 100);
|
||||||
|
const safeInitials = sanitizeText(typeof user.initials === "string" ? user.initials : "", 5);
|
||||||
|
const safeColor = sanitizeText(typeof user.color === "string" ? user.color : "", 30);
|
||||||
|
|
||||||
const roomId = `drawing_${drawingId}`;
|
const roomId = `drawing_${drawingId}`;
|
||||||
socket.join(roomId);
|
socket.join(roomId);
|
||||||
|
|
||||||
const newUser: User = { ...user, socketId: socket.id, isActive: true };
|
const newUser: User = { id: user.id, name: safeName, initials: safeInitials, color: safeColor, socketId: socket.id, isActive: true };
|
||||||
|
|
||||||
const currentUsers = roomUsers.get(roomId) || [];
|
const currentUsers = roomUsers.get(roomId) || [];
|
||||||
const filteredUsers = currentUsers.filter((u) => u.id !== user.id);
|
const filteredUsers = currentUsers.filter((u) => u.id !== user.id);
|
||||||
@@ -655,11 +731,13 @@ io.on("connection", (socket) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
socket.on("cursor-move", (data) => {
|
socket.on("cursor-move", (data) => {
|
||||||
|
if (!data || !isValidDrawingId(data.drawingId)) return;
|
||||||
const roomId = `drawing_${data.drawingId}`;
|
const roomId = `drawing_${data.drawingId}`;
|
||||||
socket.volatile.to(roomId).emit("cursor-move", data);
|
socket.volatile.to(roomId).emit("cursor-move", data);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("element-update", (data) => {
|
socket.on("element-update", (data) => {
|
||||||
|
if (!data || !isValidDrawingId(data.drawingId)) return;
|
||||||
const roomId = `drawing_${data.drawingId}`;
|
const roomId = `drawing_${data.drawingId}`;
|
||||||
socket.to(roomId).emit("element-update", data);
|
socket.to(roomId).emit("element-update", data);
|
||||||
});
|
});
|
||||||
@@ -667,6 +745,8 @@ io.on("connection", (socket) => {
|
|||||||
socket.on(
|
socket.on(
|
||||||
"user-activity",
|
"user-activity",
|
||||||
({ drawingId, isActive }: { drawingId: string; isActive: boolean }) => {
|
({ drawingId, isActive }: { drawingId: string; isActive: boolean }) => {
|
||||||
|
if (!isValidDrawingId(drawingId)) return;
|
||||||
|
if (typeof isActive !== "boolean") return;
|
||||||
const roomId = `drawing_${drawingId}`;
|
const roomId = `drawing_${drawingId}`;
|
||||||
const users = roomUsers.get(roomId);
|
const users = roomUsers.get(roomId);
|
||||||
if (users) {
|
if (users) {
|
||||||
@@ -959,8 +1039,12 @@ app.get("/collections", async (req, res) => {
|
|||||||
app.post("/collections", async (req, res) => {
|
app.post("/collections", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name } = req.body;
|
const { name } = req.body;
|
||||||
|
if (typeof name !== "string" || name.trim().length === 0 || name.trim().length > 255) {
|
||||||
|
return res.status(400).json({ error: "Collection name must be a non-empty string (max 255 characters)" });
|
||||||
|
}
|
||||||
|
const sanitizedName = sanitizeText(name.trim(), 255);
|
||||||
const newCollection = await prisma.collection.create({
|
const newCollection = await prisma.collection.create({
|
||||||
data: { name },
|
data: { name: sanitizedName },
|
||||||
});
|
});
|
||||||
res.json(newCollection);
|
res.json(newCollection);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -972,9 +1056,13 @@ app.put("/collections/:id", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { name } = req.body;
|
const { name } = req.body;
|
||||||
|
if (typeof name !== "string" || name.trim().length === 0 || name.trim().length > 255) {
|
||||||
|
return res.status(400).json({ error: "Collection name must be a non-empty string (max 255 characters)" });
|
||||||
|
}
|
||||||
|
const sanitizedName = sanitizeText(name.trim(), 255);
|
||||||
const updatedCollection = await prisma.collection.update({
|
const updatedCollection = await prisma.collection.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { name },
|
data: { name: sanitizedName },
|
||||||
});
|
});
|
||||||
res.json(updatedCollection);
|
res.json(updatedCollection);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1029,14 +1117,23 @@ app.put("/library", async (req, res) => {
|
|||||||
return res.status(400).json({ error: "Items must be an array" });
|
return res.status(400).json({ error: "Items must be an array" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (items.length > 10000) {
|
||||||
|
return res.status(400).json({ error: "Library items limit exceeded (max 10,000)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = JSON.stringify(items);
|
||||||
|
if (serialized.length > 50 * 1024 * 1024) {
|
||||||
|
return res.status(400).json({ error: "Library data too large" });
|
||||||
|
}
|
||||||
|
|
||||||
const library = await prisma.library.upsert({
|
const library = await prisma.library.upsert({
|
||||||
where: { id: "default" },
|
where: { id: "default" },
|
||||||
update: {
|
update: {
|
||||||
items: JSON.stringify(items),
|
items: serialized,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
id: "default",
|
id: "default",
|
||||||
items: JSON.stringify(items),
|
items: serialized,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1125,12 +1222,12 @@ app.get("/export/json", async (req, res) => {
|
|||||||
|
|
||||||
Object.entries(drawingsByCollection).forEach(
|
Object.entries(drawingsByCollection).forEach(
|
||||||
([collectionName, collectionDrawings]) => {
|
([collectionName, collectionDrawings]) => {
|
||||||
const folderName = collectionName.replace(/[<>:"/\\|?*]/g, "_");
|
const folderName = collectionName.replace(/[<>:"/\\|?*]/g, "_").replace(/\.\./g, "_");
|
||||||
collectionDrawings.forEach((drawing, index) => {
|
collectionDrawings.forEach((drawing, index) => {
|
||||||
const fileName = `${drawing.name.replace(
|
const fileName = `${drawing.name.replace(
|
||||||
/[<>:"/\\|?*]/g,
|
/[<>:"/\\|?*]/g,
|
||||||
"_"
|
"_"
|
||||||
)}.excalidraw`;
|
).replace(/\.\./g, "_")}.excalidraw`;
|
||||||
const filePath = `${folderName}/${fileName}`;
|
const filePath = `${folderName}/${fileName}`;
|
||||||
|
|
||||||
archive.append(JSON.stringify(drawing.data, null, 2), {
|
archive.append(JSON.stringify(drawing.data, null, 2), {
|
||||||
@@ -1222,12 +1319,27 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dbPath = getResolvedDbPath();
|
const dbPath = getResolvedDbPath();
|
||||||
const backupPath = `${dbPath}.backup`;
|
const backupTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
const backupPath = `${dbPath}.backup-${backupTimestamp}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
await fsPromises.access(dbPath);
|
await fsPromises.access(dbPath);
|
||||||
await fsPromises.copyFile(dbPath, backupPath);
|
await fsPromises.copyFile(dbPath, backupPath);
|
||||||
|
console.log(`[import] Created backup: ${backupPath}`);
|
||||||
|
|
||||||
|
// Rotate old backups - keep only the 5 most recent
|
||||||
|
const dbDir = path.dirname(dbPath);
|
||||||
|
const dbName = path.basename(dbPath);
|
||||||
|
const files = await fsPromises.readdir(dbDir);
|
||||||
|
const backups = files
|
||||||
|
.filter((f) => f.startsWith(`${dbName}.backup-`))
|
||||||
|
.sort()
|
||||||
|
.reverse();
|
||||||
|
for (const oldBackup of backups.slice(5)) {
|
||||||
|
await removeFileIfExists(path.join(dbDir, oldBackup));
|
||||||
|
console.log(`[import] Removed old backup: ${oldBackup}`);
|
||||||
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|
||||||
await moveFile(stagedPath, dbPath);
|
await moveFile(stagedPath, dbPath);
|
||||||
@@ -1266,6 +1378,17 @@ const ensureTrashCollection = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Global error handler - prevent stack traces from leaking to clients
|
||||||
|
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
if (err instanceof multer.MulterError) {
|
||||||
|
return res.status(400).json({ error: `Upload error: ${err.message}` });
|
||||||
|
}
|
||||||
|
if (err && err.message) {
|
||||||
|
console.error("Unhandled error:", err.message);
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: "Internal server error" });
|
||||||
|
});
|
||||||
|
|
||||||
httpServer.listen(PORT, async () => {
|
httpServer.listen(PORT, async () => {
|
||||||
await initializeUploadDir();
|
await initializeUploadDir();
|
||||||
await ensureTrashCollection();
|
await ensureTrashCollection();
|
||||||
|
|||||||
+17
-27
@@ -30,9 +30,7 @@ let activeConfig: SecurityConfig = { ...defaultConfig };
|
|||||||
* Configure security settings
|
* Configure security settings
|
||||||
* @param config Partial configuration to merge with defaults
|
* @param config Partial configuration to merge with defaults
|
||||||
*/
|
*/
|
||||||
export const configureSecuritySettings = (
|
export const configureSecuritySettings = (config: Partial<SecurityConfig>): void => {
|
||||||
config: Partial<SecurityConfig>
|
|
||||||
): void => {
|
|
||||||
activeConfig = { ...activeConfig, ...config };
|
activeConfig = { ...activeConfig, ...config };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -320,13 +318,10 @@ export const appStateSchema = z
|
|||||||
.optional()
|
.optional()
|
||||||
.nullable(),
|
.nullable(),
|
||||||
currentItemRoundness: z
|
currentItemRoundness: z
|
||||||
.union([
|
.object({
|
||||||
z.enum(["sharp", "round"]),
|
type: z.enum(["round", "sharp"]),
|
||||||
z.object({
|
value: z.number().finite().min(0).max(1),
|
||||||
type: z.enum(["round", "sharp"]),
|
})
|
||||||
value: z.number().finite().min(0).max(1),
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
.optional()
|
.optional()
|
||||||
.nullable(),
|
.nullable(),
|
||||||
currentItemFontSize: z
|
currentItemFontSize: z
|
||||||
@@ -432,19 +427,10 @@ export const sanitizeDrawingData = (data: {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Dangerous URL protocols to block entirely
|
// Dangerous URL protocols to block entirely
|
||||||
const dangerousProtocols = [
|
const dangerousProtocols = [/^javascript:/i, /^vbscript:/i, /^data:text\/html/i];
|
||||||
/^javascript:/i,
|
|
||||||
/^vbscript:/i,
|
|
||||||
/^data:text\/html/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Suspicious patterns for security validation within data URLs
|
// Suspicious patterns for security validation within data URLs
|
||||||
const suspiciousPatterns = [
|
const suspiciousPatterns = [/<script/i, /javascript:/i, /on\w+\s*=/i, /<iframe/i];
|
||||||
/<script/i,
|
|
||||||
/javascript:/i,
|
|
||||||
/on\w+\s*=/i,
|
|
||||||
/<iframe/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Maximum size for dataURL (configurable, default 10MB to prevent DoS)
|
// Maximum size for dataURL (configurable, default 10MB to prevent DoS)
|
||||||
const MAX_DATAURL_SIZE = activeConfig.maxDataUrlSize;
|
const MAX_DATAURL_SIZE = activeConfig.maxDataUrlSize;
|
||||||
@@ -462,8 +448,8 @@ export const sanitizeDrawingData = (data: {
|
|||||||
const normalizedValue = value.toLowerCase();
|
const normalizedValue = value.toLowerCase();
|
||||||
|
|
||||||
// First, check for dangerous protocols - block these entirely
|
// First, check for dangerous protocols - block these entirely
|
||||||
const hasDangerousProtocol = dangerousProtocols.some(
|
const hasDangerousProtocol = dangerousProtocols.some((pattern) =>
|
||||||
(pattern) => pattern.test(value)
|
pattern.test(value)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasDangerousProtocol) {
|
if (hasDangerousProtocol) {
|
||||||
@@ -479,8 +465,8 @@ export const sanitizeDrawingData = (data: {
|
|||||||
|
|
||||||
if (isSafeImageType) {
|
if (isSafeImageType) {
|
||||||
// Check for suspicious content and size limits
|
// Check for suspicious content and size limits
|
||||||
const hasSuspiciousContent = suspiciousPatterns.some(
|
const hasSuspiciousContent = suspiciousPatterns.some((pattern) =>
|
||||||
(pattern) => pattern.test(value)
|
pattern.test(value)
|
||||||
);
|
);
|
||||||
const isTooLarge = value.length > MAX_DATAURL_SIZE;
|
const isTooLarge = value.length > MAX_DATAURL_SIZE;
|
||||||
|
|
||||||
@@ -583,8 +569,12 @@ const getCsrfSecret = (): Buffer => {
|
|||||||
cachedCsrfSecret = crypto.randomBytes(32);
|
cachedCsrfSecret = crypto.randomBytes(32);
|
||||||
const envLabel = process.env.NODE_ENV ? ` (${process.env.NODE_ENV})` : "";
|
const envLabel = process.env.NODE_ENV ? ` (${process.env.NODE_ENV})` : "";
|
||||||
console.warn(
|
console.warn(
|
||||||
`[security] CSRF_SECRET is not set${envLabel}. Using an ephemeral per-process secret. ` +
|
`[SECURITY WARNING] CSRF_SECRET is not set${envLabel}.\n` +
|
||||||
"For horizontal scaling (k8s), set CSRF_SECRET to the same value on all instances."
|
`Using an ephemeral per-process secret.\n` +
|
||||||
|
` - Tokens will expire on container restart\n` +
|
||||||
|
` - Horizontal scaling (k8s) will NOT work\n` +
|
||||||
|
` - Generate a secret: openssl rand -base64 32\n` +
|
||||||
|
` - Set environment variable: CSRF_SECRET=<generated-secret>`
|
||||||
);
|
);
|
||||||
return cachedCsrfSecret;
|
return cachedCsrfSecret;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.0",
|
"version": "0.3.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -301,7 +301,6 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const persistableAppState = {
|
const persistableAppState = {
|
||||||
...appState,
|
|
||||||
viewBackgroundColor: appState?.viewBackgroundColor || '#ffffff',
|
viewBackgroundColor: appState?.viewBackgroundColor || '#ffffff',
|
||||||
gridSize: appState?.gridSize || null,
|
gridSize: appState?.gridSize || null,
|
||||||
};
|
};
|
||||||
|
|||||||
Executable
+109
@@ -0,0 +1,109 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Custom name is required
|
||||||
|
CUSTOM_NAME=$1
|
||||||
|
|
||||||
|
if [ -z "$CUSTOM_NAME" ]; then
|
||||||
|
echo -e "${RED}ERROR: Custom name is required!${NC}"
|
||||||
|
echo -e "${YELLOW}Usage: $0 <custom-name>${NC}"
|
||||||
|
echo -e "${YELLOW}Example: $0 issue38${NC}"
|
||||||
|
echo -e "${YELLOW} This will create tags like: 0.3.1-dev-issue38${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
DOCKER_USERNAME="zimengxiong"
|
||||||
|
IMAGE_NAME="excalidash"
|
||||||
|
BASE_VERSION=$(node -e "try { console.log(require('fs').readFileSync('VERSION', 'utf8').trim()) } catch { console.log('0.0.0') }")
|
||||||
|
VERSION="${BASE_VERSION}-dev-${CUSTOM_NAME}"
|
||||||
|
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||||
|
|
||||||
|
echo -e "${BLUE}===========================================${NC}"
|
||||||
|
echo -e "${BLUE}ExcaliDash Custom Dev Release${NC}"
|
||||||
|
echo -e "${BLUE}===========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Branch: ${CURRENT_BRANCH}${NC}"
|
||||||
|
echo -e "${YELLOW}Base version: ${BASE_VERSION}${NC}"
|
||||||
|
echo -e "${YELLOW}Custom name: ${CUSTOM_NAME}${NC}"
|
||||||
|
echo -e "${YELLOW}Full tag: ${VERSION}${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}This will publish images with tag: ${VERSION}${NC}"
|
||||||
|
echo -e "${YELLOW}Dev images will NOT update 'latest' or 'dev' tags${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Confirm before proceeding
|
||||||
|
read -p "Continue? (y/N) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo -e "${RED}Aborted.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if logged in to Docker Hub
|
||||||
|
echo -e "${YELLOW}Checking Docker Hub authentication...${NC}"
|
||||||
|
if ! docker info | grep -q "Username: $DOCKER_USERNAME"; then
|
||||||
|
echo -e "${YELLOW}Not logged in. Please login to Docker Hub:${NC}"
|
||||||
|
docker login
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}✓ Already logged in as $DOCKER_USERNAME${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create buildx builder if it doesn't exist
|
||||||
|
echo -e "${YELLOW}Setting up buildx builder...${NC}"
|
||||||
|
if ! docker buildx inspect excalidash-builder > /dev/null 2>&1; then
|
||||||
|
echo -e "${YELLOW}Creating new buildx builder...${NC}"
|
||||||
|
docker buildx create --name excalidash-builder --use --bootstrap
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}✓ Using existing buildx builder${NC}"
|
||||||
|
docker buildx use excalidash-builder
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build and push backend image
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}Building and pushing backend image...${NC}"
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--tag $DOCKER_USERNAME/$IMAGE_NAME-backend:$VERSION \
|
||||||
|
--file backend/Dockerfile \
|
||||||
|
--push \
|
||||||
|
backend/
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Backend image pushed successfully${NC}"
|
||||||
|
|
||||||
|
# Build and push frontend image
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}Building and pushing frontend image...${NC}"
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--tag $DOCKER_USERNAME/$IMAGE_NAME-frontend:$VERSION \
|
||||||
|
--build-arg VITE_APP_VERSION=$VERSION \
|
||||||
|
--file frontend/Dockerfile \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Frontend image pushed successfully${NC}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}===========================================${NC}"
|
||||||
|
echo -e "${GREEN}✓ Custom dev images published!${NC}"
|
||||||
|
echo -e "${BLUE}===========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Images published:${NC}"
|
||||||
|
echo -e " • $DOCKER_USERNAME/$IMAGE_NAME-backend:$VERSION"
|
||||||
|
echo -e " • $DOCKER_USERNAME/$IMAGE_NAME-frontend:$VERSION"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}To use these images in docker-compose:${NC}"
|
||||||
|
echo -e "${BLUE} services:"
|
||||||
|
echo -e " backend:"
|
||||||
|
echo -e " image: $DOCKER_USERNAME/$IMAGE_NAME-backend:$VERSION"
|
||||||
|
echo -e " frontend:"
|
||||||
|
echo -e " image: $DOCKER_USERNAME/$IMAGE_NAME-frontend:$VERSION${NC}"
|
||||||
|
echo ""
|
||||||
Reference in New Issue
Block a user