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.1",
|
"version": "0.2.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+28
-49
@@ -48,14 +48,12 @@ const resolveDatabaseUrl = (rawUrl?: string) => {
|
|||||||
const prismaDir = path.resolve(backendRoot, "prisma");
|
const prismaDir = path.resolve(backendRoot, "prisma");
|
||||||
const normalizedRelative = filePath.replace(/^\.\/?/, "");
|
const normalizedRelative = filePath.replace(/^\.\/?/, "");
|
||||||
const hasLeadingPrismaDir =
|
const hasLeadingPrismaDir =
|
||||||
normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/");
|
normalizedRelative === "prisma" ||
|
||||||
|
normalizedRelative.startsWith("prisma/");
|
||||||
|
|
||||||
const absolutePath = path.isAbsolute(filePath)
|
const absolutePath = path.isAbsolute(filePath)
|
||||||
? filePath
|
? filePath
|
||||||
: path.resolve(
|
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
|
||||||
hasLeadingPrismaDir ? backendRoot : prismaDir,
|
|
||||||
normalizedRelative,
|
|
||||||
);
|
|
||||||
|
|
||||||
return `file:${absolutePath}`;
|
return `file:${absolutePath}`;
|
||||||
};
|
};
|
||||||
@@ -131,12 +129,6 @@ 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
|
|
||||||
// This fixes CSRF token validation failures in Docker/K8s environments
|
|
||||||
app.set("trust proxy", 1);
|
|
||||||
|
|
||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
const io = new Server(httpServer, {
|
const io = new Server(httpServer, {
|
||||||
cors: {
|
cors: {
|
||||||
@@ -148,7 +140,7 @@ const io = new Server(httpServer, {
|
|||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
const parseJsonField = <T>(
|
const parseJsonField = <T>(
|
||||||
rawValue: string | null | undefined,
|
rawValue: string | null | undefined,
|
||||||
fallback: T,
|
fallback: T
|
||||||
): T => {
|
): T => {
|
||||||
if (!rawValue) return fallback;
|
if (!rawValue) return fallback;
|
||||||
try {
|
try {
|
||||||
@@ -242,7 +234,7 @@ app.use(
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"],
|
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"],
|
||||||
exposedHeaders: ["x-csrf-token"],
|
exposedHeaders: ["x-csrf-token"],
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
app.use(express.json({ limit: "50mb" }));
|
app.use(express.json({ limit: "50mb" }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: "50mb" }));
|
app.use(express.urlencoded({ extended: true, limit: "50mb" }));
|
||||||
@@ -254,8 +246,8 @@ app.use((req, res, next) => {
|
|||||||
if (sizeInMB > 10) {
|
if (sizeInMB > 10) {
|
||||||
console.log(
|
console.log(
|
||||||
`[LARGE REQUEST] ${req.method} ${req.path} - ${sizeInMB.toFixed(
|
`[LARGE REQUEST] ${req.method} ${req.path} - ${sizeInMB.toFixed(
|
||||||
2,
|
2
|
||||||
)}MB - Content-Length: ${contentLength} bytes`,
|
)}MB - Content-Length: ${contentLength} bytes`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,7 +261,7 @@ app.use((req, res, next) => {
|
|||||||
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
"Permissions-Policy",
|
"Permissions-Policy",
|
||||||
"geolocation=(), microphone=(), camera=()",
|
"geolocation=(), microphone=(), camera=()"
|
||||||
);
|
);
|
||||||
|
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
@@ -280,7 +272,7 @@ 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';"
|
||||||
);
|
);
|
||||||
|
|
||||||
next();
|
next();
|
||||||
@@ -289,17 +281,14 @@ app.use((req, res, next) => {
|
|||||||
const requestCounts = new Map<string, { count: number; resetTime: number }>();
|
const requestCounts = new Map<string, { count: number; resetTime: number }>();
|
||||||
const RATE_LIMIT_WINDOW = 15 * 60 * 1000;
|
const RATE_LIMIT_WINDOW = 15 * 60 * 1000;
|
||||||
|
|
||||||
setInterval(
|
setInterval(() => {
|
||||||
() => {
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [ip, data] of requestCounts.entries()) {
|
for (const [ip, data] of requestCounts.entries()) {
|
||||||
if (now > data.resetTime) {
|
if (now > data.resetTime) {
|
||||||
requestCounts.delete(ip);
|
requestCounts.delete(ip);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}, 5 * 60 * 1000).unref();
|
||||||
5 * 60 * 1000,
|
|
||||||
).unref();
|
|
||||||
|
|
||||||
const RATE_LIMIT_MAX_REQUESTS = (() => {
|
const RATE_LIMIT_MAX_REQUESTS = (() => {
|
||||||
const parsed = Number(process.env.RATE_LIMIT_MAX_REQUESTS);
|
const parsed = Number(process.env.RATE_LIMIT_MAX_REQUESTS);
|
||||||
@@ -366,10 +355,7 @@ app.get("/csrf-token", (req, res) => {
|
|||||||
}
|
}
|
||||||
clientLimit.count++;
|
clientLimit.count++;
|
||||||
} else {
|
} else {
|
||||||
csrfRateLimit.set(ip, {
|
csrfRateLimit.set(ip, { count: 1, resetTime: now + CSRF_RATE_LIMIT_WINDOW });
|
||||||
count: 1,
|
|
||||||
resetTime: now + CSRF_RATE_LIMIT_WINDOW,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup old rate limit entries occasionally
|
// Cleanup old rate limit entries occasionally
|
||||||
@@ -384,7 +370,7 @@ app.get("/csrf-token", (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
token,
|
token,
|
||||||
header: getCsrfTokenHeader(),
|
header: getCsrfTokenHeader()
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -392,7 +378,7 @@ app.get("/csrf-token", (req, res) => {
|
|||||||
const csrfProtectionMiddleware = (
|
const csrfProtectionMiddleware = (
|
||||||
req: express.Request,
|
req: express.Request,
|
||||||
res: express.Response,
|
res: express.Response,
|
||||||
next: express.NextFunction,
|
next: express.NextFunction
|
||||||
) => {
|
) => {
|
||||||
// Skip CSRF validation for safe methods (GET, HEAD, OPTIONS)
|
// Skip CSRF validation for safe methods (GET, HEAD, OPTIONS)
|
||||||
// Note: /csrf-token is a GET endpoint, so it's automatically exempt
|
// Note: /csrf-token is a GET endpoint, so it's automatically exempt
|
||||||
@@ -485,7 +471,7 @@ const drawingCreateSchema = drawingBaseSchema
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: "Invalid or malicious drawing data detected",
|
message: "Invalid or malicious drawing data detected",
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const drawingUpdateSchema = drawingBaseSchema
|
const drawingUpdateSchema = drawingBaseSchema
|
||||||
@@ -535,12 +521,12 @@ const drawingUpdateSchema = drawingBaseSchema
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: "Invalid or malicious drawing data detected",
|
message: "Invalid or malicious drawing data detected",
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const respondWithValidationErrors = (
|
const respondWithValidationErrors = (
|
||||||
res: express.Response,
|
res: express.Response,
|
||||||
issues: z.ZodIssue[],
|
issues: z.ZodIssue[]
|
||||||
) => {
|
) => {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
error: "Invalid drawing payload",
|
error: "Invalid drawing payload",
|
||||||
@@ -590,7 +576,7 @@ const verifyDatabaseIntegrityAsync = (filePath: string): Promise<boolean> => {
|
|||||||
path.resolve(__dirname, "./workers/db-verify.js"),
|
path.resolve(__dirname, "./workers/db-verify.js"),
|
||||||
{
|
{
|
||||||
workerData: { filePath },
|
workerData: { filePath },
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
let timeoutHandle: NodeJS.Timeout;
|
let timeoutHandle: NodeJS.Timeout;
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -665,7 +651,7 @@ io.on("connection", (socket) => {
|
|||||||
roomUsers.set(roomId, filteredUsers);
|
roomUsers.set(roomId, filteredUsers);
|
||||||
|
|
||||||
io.to(roomId).emit("presence-update", filteredUsers);
|
io.to(roomId).emit("presence-update", filteredUsers);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
socket.on("cursor-move", (data) => {
|
socket.on("cursor-move", (data) => {
|
||||||
@@ -690,7 +676,7 @@ io.on("connection", (socket) => {
|
|||||||
io.to(roomId).emit("presence-update", users);
|
io.to(roomId).emit("presence-update", users);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
socket.on("disconnect", () => {
|
socket.on("disconnect", () => {
|
||||||
@@ -1081,9 +1067,8 @@ app.get("/export", async (req, res) => {
|
|||||||
res.setHeader("Content-Type", "application/octet-stream");
|
res.setHeader("Content-Type", "application/octet-stream");
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
`attachment; filename="excalidash-db-${
|
`attachment; filename="excalidash-db-${new Date().toISOString().split("T")[0]
|
||||||
new Date().toISOString().split("T")[0]
|
}.${extension}"`
|
||||||
}.${extension}"`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const fileStream = fs.createReadStream(dbPath);
|
const fileStream = fs.createReadStream(dbPath);
|
||||||
@@ -1105,9 +1090,8 @@ app.get("/export/json", async (req, res) => {
|
|||||||
res.setHeader("Content-Type", "application/zip");
|
res.setHeader("Content-Type", "application/zip");
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
`attachment; filename="excalidraw-drawings-${
|
`attachment; filename="excalidraw-drawings-${new Date().toISOString().split("T")[0]
|
||||||
new Date().toISOString().split("T")[0]
|
}.zip"`
|
||||||
}.zip"`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const archive = archiver("zip", { zlib: { level: 9 } });
|
const archive = archiver("zip", { zlib: { level: 9 } });
|
||||||
@@ -1121,8 +1105,6 @@ app.get("/export/json", async (req, res) => {
|
|||||||
|
|
||||||
const drawingsByCollection: { [key: string]: any[] } = {};
|
const drawingsByCollection: { [key: string]: any[] } = {};
|
||||||
|
|
||||||
const exportSource = `${req.protocol}://${req.get("host")}`;
|
|
||||||
|
|
||||||
drawings.forEach((drawing: any) => {
|
drawings.forEach((drawing: any) => {
|
||||||
const collectionName = drawing.collection?.name || "Unorganized";
|
const collectionName = drawing.collection?.name || "Unorganized";
|
||||||
if (!drawingsByCollection[collectionName]) {
|
if (!drawingsByCollection[collectionName]) {
|
||||||
@@ -1130,9 +1112,6 @@ app.get("/export/json", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const drawingData = {
|
const drawingData = {
|
||||||
type: "excalidraw",
|
|
||||||
version: 2,
|
|
||||||
source: exportSource,
|
|
||||||
elements: JSON.parse(drawing.elements),
|
elements: JSON.parse(drawing.elements),
|
||||||
appState: JSON.parse(drawing.appState),
|
appState: JSON.parse(drawing.appState),
|
||||||
files: JSON.parse(drawing.files || "{}"),
|
files: JSON.parse(drawing.files || "{}"),
|
||||||
@@ -1150,7 +1129,7 @@ app.get("/export/json", async (req, res) => {
|
|||||||
collectionDrawings.forEach((drawing, index) => {
|
collectionDrawings.forEach((drawing, index) => {
|
||||||
const fileName = `${drawing.name.replace(
|
const fileName = `${drawing.name.replace(
|
||||||
/[<>:"/\\|?*]/g,
|
/[<>:"/\\|?*]/g,
|
||||||
"_",
|
"_"
|
||||||
)}.excalidraw`;
|
)}.excalidraw`;
|
||||||
const filePath = `${folderName}/${fileName}`;
|
const filePath = `${folderName}/${fileName}`;
|
||||||
|
|
||||||
@@ -1158,7 +1137,7 @@ app.get("/export/json", async (req, res) => {
|
|||||||
name: filePath,
|
name: filePath,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const readmeContent = `ExcaliDash Export
|
const readmeContent = `ExcaliDash Export
|
||||||
@@ -1222,7 +1201,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
|
|||||||
const originalPath = req.file.path;
|
const originalPath = req.file.path;
|
||||||
const stagedPath = path.join(
|
const stagedPath = path.join(
|
||||||
uploadDir,
|
uploadDir,
|
||||||
`temp-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
|
`temp-${Date.now()}-${Math.random().toString(36).slice(2)}.db`
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1249,7 +1228,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
await fsPromises.access(dbPath);
|
await fsPromises.access(dbPath);
|
||||||
await fsPromises.copyFile(dbPath, backupPath);
|
await fsPromises.copyFile(dbPath, backupPath);
|
||||||
} catch {}
|
} catch { }
|
||||||
|
|
||||||
await moveFile(stagedPath, dbPath);
|
await moveFile(stagedPath, dbPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -532,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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.1",
|
"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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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