Optimized the root .gitignore to exclude virtual environments, node modules, and temp folders to ensure clean and lightweight version tracking. Co-authored-by: Cursor <cursoragent@cursor.com>
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import type { Plugin } from 'vite';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import { sanitizeDocBaseName } from './utils/docUtils';
|
|
import { getRequestPathname, readJsonBody } from './utils/httpUtils';
|
|
|
|
export function canvasApiPlugin(): Plugin {
|
|
const CANVAS_EXT = '.excalidraw';
|
|
const DEFAULT_CANVAS_DATA = JSON.stringify({
|
|
type: 'excalidraw',
|
|
version: 2,
|
|
source: 'axhub-make',
|
|
elements: [],
|
|
appState: { gridSize: null, viewBackgroundColor: '#ffffff' },
|
|
files: {},
|
|
}, null, 2);
|
|
|
|
return {
|
|
name: 'canvas-api-plugin',
|
|
configureServer(server: any) {
|
|
server.middlewares.use(async (req: any, res: any, next: any) => {
|
|
const pathname = getRequestPathname(req);
|
|
if (!pathname.startsWith('/api/canvas')) {
|
|
return next();
|
|
}
|
|
const canvasDir = path.resolve(process.cwd(), 'src/canvas');
|
|
|
|
if (
|
|
req.method === 'POST' &&
|
|
(pathname === '/api/canvas/create' || pathname === '/api/canvas/create/')
|
|
) {
|
|
try {
|
|
const body = await readJsonBody(req);
|
|
const displayName = String(body?.displayName || '').trim();
|
|
|
|
fs.mkdirSync(canvasDir, { recursive: true });
|
|
|
|
const fallbackBase = `canvas-${Date.now().toString(36)}`;
|
|
const sanitizedBase = sanitizeDocBaseName(displayName || fallbackBase) || fallbackBase;
|
|
let baseName = sanitizedBase;
|
|
let suffix = 2;
|
|
while (fs.existsSync(path.join(canvasDir, `${baseName}${CANVAS_EXT}`))) {
|
|
baseName = `${sanitizedBase}-${suffix}`;
|
|
suffix += 1;
|
|
}
|
|
|
|
const fileName = `${baseName}${CANVAS_EXT}`;
|
|
const filePath = path.join(canvasDir, fileName);
|
|
|
|
if (!filePath.startsWith(canvasDir)) {
|
|
res.statusCode = 403;
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
return;
|
|
}
|
|
|
|
fs.writeFileSync(filePath, DEFAULT_CANVAS_DATA, 'utf8');
|
|
|
|
res.statusCode = 201;
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
res.end(JSON.stringify({
|
|
success: true,
|
|
name: fileName,
|
|
displayName: baseName,
|
|
path: `src/canvas/${fileName}`,
|
|
}));
|
|
} catch (error: any) {
|
|
console.error('Error creating canvas:', error);
|
|
res.statusCode = 500;
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
res.end(JSON.stringify({ error: error?.message || 'Create canvas failed' }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'POST' && pathname.startsWith('/api/canvas/') && pathname.endsWith('/copy')) {
|
|
try {
|
|
const encodedName = pathname.slice('/api/canvas/'.length, -'/copy'.length);
|
|
const canvasName = decodeURIComponent(encodedName);
|
|
if (!canvasName) {
|
|
res.statusCode = 400;
|
|
res.end(JSON.stringify({ error: 'Missing canvas name' }));
|
|
return;
|
|
}
|
|
|
|
const sourcePath = path.join(canvasDir, canvasName);
|
|
if (!sourcePath.startsWith(canvasDir)) {
|
|
res.statusCode = 403;
|
|
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
return;
|
|
}
|
|
if (!fs.existsSync(sourcePath)) {
|
|
res.statusCode = 404;
|
|
res.end(JSON.stringify({ error: 'Canvas not found' }));
|
|
return;
|
|
}
|
|
|
|
const sourceBaseName = path.basename(sourcePath, CANVAS_EXT);
|
|
const safeBaseName = sanitizeDocBaseName(sourceBaseName) || sourceBaseName;
|
|
const candidateBase = `${safeBaseName}-copy`;
|
|
|
|
let nextBaseName = candidateBase;
|
|
let suffix = 2;
|
|
let nextName = `${nextBaseName}${CANVAS_EXT}`;
|
|
let nextPath = path.join(canvasDir, nextName);
|
|
while (fs.existsSync(nextPath)) {
|
|
nextBaseName = `${candidateBase}${suffix}`;
|
|
nextName = `${nextBaseName}${CANVAS_EXT}`;
|
|
nextPath = path.join(canvasDir, nextName);
|
|
suffix += 1;
|
|
}
|
|
|
|
if (!nextPath.startsWith(canvasDir)) {
|
|
res.statusCode = 403;
|
|
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
return;
|
|
}
|
|
|
|
fs.copyFileSync(sourcePath, nextPath);
|
|
|
|
res.statusCode = 201;
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
res.end(JSON.stringify({
|
|
success: true,
|
|
name: nextName,
|
|
displayName: nextBaseName,
|
|
path: `src/canvas/${nextName}`,
|
|
}));
|
|
} catch (error: any) {
|
|
console.error('Error copying canvas:', error);
|
|
res.statusCode = 500;
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
res.end(JSON.stringify({ error: error?.message || 'Copy canvas failed' }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'DELETE' && pathname.startsWith('/api/canvas/')) {
|
|
try {
|
|
const encodedName = pathname.replace('/api/canvas/', '');
|
|
const canvasName = decodeURIComponent(encodedName);
|
|
if (!canvasName) {
|
|
res.statusCode = 400;
|
|
res.end(JSON.stringify({ error: 'Missing canvas name' }));
|
|
return;
|
|
}
|
|
const filePath = path.join(canvasDir, canvasName);
|
|
|
|
if (!filePath.startsWith(canvasDir)) {
|
|
res.statusCode = 403;
|
|
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
return;
|
|
}
|
|
if (!fs.existsSync(filePath)) {
|
|
res.statusCode = 404;
|
|
res.end(JSON.stringify({ error: 'Canvas not found' }));
|
|
return;
|
|
}
|
|
|
|
fs.unlinkSync(filePath);
|
|
res.statusCode = 200;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({ success: true }));
|
|
} catch (error: any) {
|
|
console.error('Error deleting canvas:', error);
|
|
res.statusCode = 500;
|
|
res.end(JSON.stringify({ error: error.message }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'PUT' && pathname.startsWith('/api/canvas/')) {
|
|
try {
|
|
const encodedName = pathname.replace('/api/canvas/', '');
|
|
if (!encodedName) {
|
|
res.statusCode = 400;
|
|
res.end(JSON.stringify({ error: 'Missing canvas name' }));
|
|
return;
|
|
}
|
|
|
|
const bodyData = await readJsonBody(req);
|
|
const hasContentUpdate = typeof bodyData?.content === 'string';
|
|
let newBaseName = String(bodyData?.newBaseName || '').trim();
|
|
const hasRename = Boolean(newBaseName);
|
|
|
|
if (!hasContentUpdate && !hasRename) {
|
|
res.statusCode = 400;
|
|
res.end(JSON.stringify({ error: 'Missing content or newBaseName parameter' }));
|
|
return;
|
|
}
|
|
if (hasRename && /[/\\:*?"<>|]/.test(newBaseName)) {
|
|
res.statusCode = 400;
|
|
res.end(JSON.stringify({ error: 'Invalid newBaseName format' }));
|
|
return;
|
|
}
|
|
|
|
const canvasName = decodeURIComponent(encodedName);
|
|
const oldPath = path.join(canvasDir, canvasName);
|
|
if (!oldPath.startsWith(canvasDir)) {
|
|
res.statusCode = 403;
|
|
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
return;
|
|
}
|
|
if (!fs.existsSync(oldPath)) {
|
|
res.statusCode = 404;
|
|
res.end(JSON.stringify({ error: 'Canvas not found' }));
|
|
return;
|
|
}
|
|
|
|
let finalPath = oldPath;
|
|
if (hasRename) {
|
|
if (newBaseName.toLowerCase().endsWith(CANVAS_EXT)) {
|
|
newBaseName = newBaseName.slice(0, -CANVAS_EXT.length).trim();
|
|
}
|
|
const safeBaseName = sanitizeDocBaseName(newBaseName);
|
|
if (!safeBaseName) {
|
|
res.statusCode = 400;
|
|
res.end(JSON.stringify({ error: 'Invalid newBaseName format' }));
|
|
return;
|
|
}
|
|
|
|
const newFileName = `${safeBaseName}${CANVAS_EXT}`;
|
|
const newPath = path.join(canvasDir, newFileName);
|
|
|
|
if (!newPath.startsWith(canvasDir)) {
|
|
res.statusCode = 403;
|
|
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
return;
|
|
}
|
|
if (newPath !== oldPath && fs.existsSync(newPath)) {
|
|
res.statusCode = 400;
|
|
res.end(JSON.stringify({ error: '目标文件已存在' }));
|
|
return;
|
|
}
|
|
|
|
if (newPath !== oldPath) {
|
|
fs.renameSync(oldPath, newPath);
|
|
}
|
|
finalPath = newPath;
|
|
}
|
|
|
|
if (hasContentUpdate) {
|
|
fs.writeFileSync(finalPath, String(bodyData.content), 'utf8');
|
|
}
|
|
|
|
const relativeName = path.basename(finalPath);
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({ success: true, name: relativeName }));
|
|
} catch (error: any) {
|
|
console.error('Error updating canvas:', error);
|
|
res.statusCode = 500;
|
|
res.end(JSON.stringify({ error: error.message }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.method !== 'GET') {
|
|
return next();
|
|
}
|
|
|
|
if (pathname.startsWith('/api/canvas/') && pathname !== '/api/canvas' && pathname !== '/api/canvas/') {
|
|
try {
|
|
const encodedName = pathname.replace('/api/canvas/', '');
|
|
if (!encodedName) {
|
|
return next();
|
|
}
|
|
|
|
const canvasName = decodeURIComponent(encodedName);
|
|
const filePath = path.join(canvasDir, canvasName);
|
|
|
|
if (!filePath.startsWith(canvasDir)) {
|
|
res.statusCode = 403;
|
|
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
return;
|
|
}
|
|
|
|
if (fs.existsSync(filePath)) {
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
res.end(content);
|
|
} else {
|
|
res.statusCode = 404;
|
|
res.end(JSON.stringify({ error: 'Canvas not found' }));
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Error loading canvas:', error);
|
|
res.statusCode = 500;
|
|
res.end(JSON.stringify({ error: error.message }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (pathname === '/api/canvas' || pathname === '/api/canvas/') {
|
|
try {
|
|
const items: Array<{ name: string; displayName: string }> = [];
|
|
|
|
if (fs.existsSync(canvasDir)) {
|
|
const entries = fs.readdirSync(canvasDir, { withFileTypes: true });
|
|
entries.forEach((entry) => {
|
|
if (!entry.isFile()) return;
|
|
if (!entry.name.endsWith(CANVAS_EXT)) return;
|
|
const baseName = entry.name.slice(0, -CANVAS_EXT.length);
|
|
items.push({
|
|
name: entry.name,
|
|
displayName: baseName,
|
|
});
|
|
});
|
|
}
|
|
|
|
items.sort((a, b) => a.name.localeCompare(b.name));
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify(items));
|
|
} catch (error: any) {
|
|
console.error('Error listing canvases:', error);
|
|
res.statusCode = 500;
|
|
res.end(JSON.stringify({ error: error.message }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
next();
|
|
});
|
|
},
|
|
};
|
|
}
|