Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44fb456405 | |||
| 8f9b9b4945 | |||
| cae8f3cbf6 | |||
| e4e48b13d8 | |||
| 8a78b2bb2e |
@@ -1,6 +1,6 @@
|
|||||||
<img src="logoExcaliDash.png" alt="ExcaliDash Logo" width="80" height="88">
|
<img src="logoExcaliDash.png" alt="ExcaliDash Logo" width="80" height="88">
|
||||||
|
|
||||||
# ExcaliDash
|
# ExcaliDash v0.1.8
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|||||||
-14
@@ -27,17 +27,3 @@ CSRF Protection (8a78b2b)
|
|||||||
- Updated docker-compose configurations with new environment variables
|
- Updated docker-compose configurations with new environment variables
|
||||||
- E2E test suite improvements and reliability fixes
|
- E2E test suite improvements and reliability fixes
|
||||||
- Added Kubernetes deployment note in README
|
- Added Kubernetes deployment note in README
|
||||||
|
|
||||||
### Kubernetes
|
|
||||||
|
|
||||||
A `CSRF_SECRET` environment variable is now required for CSRF protection. Generate a secure 32+ character random string:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openssl rand -base64 32
|
|
||||||
|
|
||||||
Add it to your deployment:
|
|
||||||
- Docker Compose: Add CSRF_SECRET=<your-secret> to the backend service environment
|
|
||||||
- Kubernetes: Add to your ConfigMap/Secret and reference in the backend deployment
|
|
||||||
|
|
||||||
If not set, the backend will refuse to start.
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "0.3.0",
|
"version": "0.2.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+13
-26
@@ -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;
|
||||||
|
|
||||||
@@ -546,6 +532,7 @@ export const validateImportedDrawing = (data: any): boolean => {
|
|||||||
// CSRF Protection
|
// CSRF Protection
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
const CSRF_TOKEN_LENGTH = 32;
|
||||||
const CSRF_TOKEN_HEADER = "x-csrf-token";
|
const CSRF_TOKEN_HEADER = "x-csrf-token";
|
||||||
const CSRF_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
const CSRF_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
const CSRF_TOKEN_FUTURE_SKEW_MS = 5 * 60 * 1000; // 5 minutes clock skew tolerance
|
const CSRF_TOKEN_FUTURE_SKEW_MS = 5 * 60 * 1000; // 5 minutes clock skew tolerance
|
||||||
@@ -584,7 +571,7 @@ const getCsrfSecret = (): Buffer => {
|
|||||||
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] CSRF_SECRET is not set${envLabel}. Using an ephemeral per-process secret. ` +
|
||||||
"For horizontal scaling (k8s), set CSRF_SECRET to the same value on all instances."
|
"For horizontal scaling (k8s), set CSRF_SECRET to the same value on all instances."
|
||||||
);
|
);
|
||||||
return cachedCsrfSecret;
|
return cachedCsrfSecret;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.0",
|
"version": "0.2.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
|
|
||||||
const uploadFiles = useCallback(async (files: File[], targetCollectionId: string | null) => {
|
const uploadFiles = useCallback(async (files: File[], targetCollectionId: string | null) => {
|
||||||
const newTasks: UploadTask[] = files.map(f => ({
|
const newTasks: UploadTask[] = files.map(f => ({
|
||||||
id: crypto.randomUUID(),
|
id: Math.random().toString(36).substring(2, 11),
|
||||||
fileName: f.name,
|
fileName: f.name,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
progress: 0
|
progress: 0
|
||||||
@@ -56,12 +56,12 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
|
|
||||||
setTasks(prev => [...newTasks, ...prev]);
|
setTasks(prev => [...newTasks, ...prev]);
|
||||||
|
|
||||||
// Map file index to task ID for progress callbacks (handles duplicate filenames)
|
// Map file names to task IDs for progress callbacks
|
||||||
const indexToTaskId = new Map<number, string>();
|
const fileTaskMap = new Map<string, string>();
|
||||||
newTasks.forEach((t, index) => indexToTaskId.set(index, t.id));
|
newTasks.forEach(t => fileTaskMap.set(t.fileName, t.id));
|
||||||
|
|
||||||
const handleProgress = (fileIndex: number, status: UploadStatus, progress: number, error?: string) => {
|
const handleProgress = (fileName: string, status: UploadStatus, progress: number, error?: string) => {
|
||||||
const taskId = indexToTaskId.get(fileIndex);
|
const taskId = fileTaskMap.get(fileName);
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
updateTask(taskId, { status, progress, error });
|
updateTask(taskId, { status, progress, error });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const importDrawings = async (
|
|||||||
targetCollectionId: string | null,
|
targetCollectionId: string | null,
|
||||||
onSuccess?: () => void | Promise<void>,
|
onSuccess?: () => void | Promise<void>,
|
||||||
onProgress?: (
|
onProgress?: (
|
||||||
fileIndex: number,
|
fileName: string,
|
||||||
status: UploadStatus,
|
status: UploadStatus,
|
||||||
progress: number,
|
progress: number,
|
||||||
error?: string
|
error?: string
|
||||||
@@ -25,20 +25,12 @@ export const importDrawings = async (
|
|||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
// Build a map from drawingFile index to original file index for progress reporting
|
|
||||||
const originalIndexMap = new Map<number, number>();
|
|
||||||
drawingFiles.forEach((df, i) => {
|
|
||||||
const originalIndex = files.indexOf(df);
|
|
||||||
originalIndexMap.set(i, originalIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
// We process files in parallel (Promise.all) but we could limit concurrency if needed.
|
// We process files in parallel (Promise.all) but we could limit concurrency if needed.
|
||||||
// For now, full parallel is fine as browser limits connection count anyway.
|
// For now, full parallel is fine as browser limits connection count anyway.
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
drawingFiles.map(async (file, drawingIndex) => {
|
drawingFiles.map(async (file) => {
|
||||||
const fileIndex = originalIndexMap.get(drawingIndex) ?? drawingIndex;
|
|
||||||
try {
|
try {
|
||||||
if (onProgress) onProgress(fileIndex, 'processing', 0); // Parsing phase
|
if (onProgress) onProgress(file.name, 'processing', 0); // Parsing phase
|
||||||
|
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const data = JSON.parse(text);
|
const data = JSON.parse(text);
|
||||||
@@ -69,7 +61,7 @@ export const importDrawings = async (
|
|||||||
preview: svg.outerHTML,
|
preview: svg.outerHTML,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (onProgress) onProgress(fileIndex, 'uploading', 0);
|
if (onProgress) onProgress(file.name, 'uploading', 0);
|
||||||
|
|
||||||
await api.post("/drawings", payload, {
|
await api.post("/drawings", payload, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -81,12 +73,12 @@ export const importDrawings = async (
|
|||||||
const percentCompleted = Math.round(
|
const percentCompleted = Math.round(
|
||||||
(progressEvent.loaded * 100) / progressEvent.total
|
(progressEvent.loaded * 100) / progressEvent.total
|
||||||
);
|
);
|
||||||
onProgress(fileIndex, 'uploading', percentCompleted);
|
onProgress(file.name, 'uploading', percentCompleted);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (onProgress) onProgress(fileIndex, 'success', 100);
|
if (onProgress) onProgress(file.name, 'success', 100);
|
||||||
successCount++;
|
successCount++;
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -98,7 +90,7 @@ export const importDrawings = async (
|
|||||||
err?.message ||
|
err?.message ||
|
||||||
"Upload failed";
|
"Upload failed";
|
||||||
errors.push(`${file.name}: ${errorMessage}`);
|
errors.push(`${file.name}: ${errorMessage}`);
|
||||||
if (onProgress) onProgress(fileIndex, 'error', 0, errorMessage);
|
if (onProgress) onProgress(file.name, 'error', 0, errorMessage);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,16 +15,19 @@ try {
|
|||||||
console.warn("Unable to read VERSION file:", error);
|
console.warn("Unable to read VERSION file:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const appVersion = process.env.VITE_APP_VERSION?.trim() || versionFromFile;
|
if (
|
||||||
const buildLabel = process.env.VITE_APP_BUILD_LABEL?.trim() || "local development build";
|
!process.env.VITE_APP_VERSION ||
|
||||||
|
process.env.VITE_APP_VERSION.trim().length === 0
|
||||||
|
) {
|
||||||
|
process.env.VITE_APP_VERSION = versionFromFile;
|
||||||
|
if (!process.env.VITE_APP_BUILD_LABEL) {
|
||||||
|
process.env.VITE_APP_BUILD_LABEL = "local development build";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
define: {
|
|
||||||
'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion),
|
|
||||||
'import.meta.env.VITE_APP_BUILD_LABEL': JSON.stringify(buildLabel),
|
|
||||||
},
|
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
|
|||||||
Reference in New Issue
Block a user