2026-03-31 18:47:00 +03:00
|
|
|
export const registerProjectIconRoutes = (app, dependencies) => {
|
|
|
|
|
const {
|
|
|
|
|
fsPromises,
|
|
|
|
|
path,
|
|
|
|
|
crypto,
|
|
|
|
|
openchamberDataDir,
|
|
|
|
|
sanitizeProjects,
|
|
|
|
|
readSettingsFromDiskMigrated,
|
|
|
|
|
persistSettings,
|
|
|
|
|
createFsSearchRuntime,
|
|
|
|
|
spawn,
|
|
|
|
|
resolveGitBinaryForSpawn,
|
|
|
|
|
} = dependencies;
|
|
|
|
|
|
|
|
|
|
const projectIconsDirPath = path.join(openchamberDataDir, 'project-icons');
|
|
|
|
|
const projectIconMimeToExtension = {
|
|
|
|
|
'image/png': 'png',
|
|
|
|
|
'image/jpeg': 'jpg',
|
|
|
|
|
'image/svg+xml': 'svg',
|
|
|
|
|
'image/webp': 'webp',
|
|
|
|
|
'image/x-icon': 'ico',
|
|
|
|
|
};
|
|
|
|
|
const projectIconExtensionToMime = Object.fromEntries(
|
|
|
|
|
Object.entries(projectIconMimeToExtension).map(([mime, ext]) => [ext, mime])
|
|
|
|
|
);
|
|
|
|
|
const projectIconSupportedMimes = new Set(Object.keys(projectIconMimeToExtension));
|
|
|
|
|
const projectIconMaxBytes = 5 * 1024 * 1024;
|
|
|
|
|
const projectIconThemeColors = {
|
|
|
|
|
light: '#111111',
|
|
|
|
|
dark: '#f5f5f5',
|
|
|
|
|
};
|
|
|
|
|
const projectIconHexColorPattern = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{4}|[\da-fA-F]{6}|[\da-fA-F]{8})$/;
|
|
|
|
|
|
|
|
|
|
const normalizeProjectIconMime = (value) => {
|
|
|
|
|
if (typeof value !== 'string') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const normalized = value.trim().toLowerCase();
|
|
|
|
|
if (normalized === 'image/jpg') {
|
|
|
|
|
return 'image/jpeg';
|
|
|
|
|
}
|
|
|
|
|
if (projectIconSupportedMimes.has(normalized)) {
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const projectIconBaseName = (projectId) => {
|
|
|
|
|
const hash = crypto.createHash('sha1').update(projectId).digest('hex');
|
|
|
|
|
return `project-${hash}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const projectIconPathForMime = (projectId, mime) => {
|
|
|
|
|
const normalizedMime = normalizeProjectIconMime(mime);
|
|
|
|
|
if (!normalizedMime) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const ext = projectIconMimeToExtension[normalizedMime];
|
|
|
|
|
return path.join(projectIconsDirPath, `${projectIconBaseName(projectId)}.${ext}`);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const projectIconPathCandidates = (projectId) => {
|
|
|
|
|
const base = projectIconBaseName(projectId);
|
|
|
|
|
return Object.values(projectIconMimeToExtension).map((ext) => path.join(projectIconsDirPath, `${base}.${ext}`));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const removeProjectIconFiles = async (projectId, keepPath) => {
|
|
|
|
|
const candidates = projectIconPathCandidates(projectId);
|
|
|
|
|
await Promise.all(candidates.map(async (candidatePath) => {
|
|
|
|
|
if (keepPath && candidatePath === keepPath) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await fsPromises.unlink(candidatePath);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (!error || typeof error !== 'object' || error.code !== 'ENOENT') {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const parseProjectIconDataUrl = (value) => {
|
|
|
|
|
if (typeof value !== 'string') {
|
|
|
|
|
return { ok: false, error: 'dataUrl is required' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const trimmed = value.trim();
|
|
|
|
|
const match = trimmed.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/i);
|
|
|
|
|
if (!match) {
|
|
|
|
|
return { ok: false, error: 'Invalid dataUrl format' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const mime = normalizeProjectIconMime(match[1]);
|
|
|
|
|
if (!mime || !['image/png', 'image/jpeg', 'image/svg+xml'].includes(mime)) {
|
|
|
|
|
return { ok: false, error: 'Icon must be PNG, JPEG, or SVG' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const base64 = match[2].replace(/\s+/g, '');
|
|
|
|
|
const bytes = Buffer.from(base64, 'base64');
|
|
|
|
|
if (bytes.length === 0) {
|
|
|
|
|
return { ok: false, error: 'Icon content is empty' };
|
|
|
|
|
}
|
|
|
|
|
if (bytes.length > projectIconMaxBytes) {
|
|
|
|
|
return { ok: false, error: 'Icon exceeds size limit (5 MB)' };
|
|
|
|
|
}
|
|
|
|
|
return { ok: true, mime, bytes };
|
|
|
|
|
} catch {
|
|
|
|
|
return { ok: false, error: 'Failed to decode icon data' };
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const normalizeProjectIconThemeVariant = (value) => {
|
|
|
|
|
if (typeof value !== 'string') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const normalized = value.trim().toLowerCase();
|
|
|
|
|
if (normalized === 'light' || normalized === 'dark') {
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const normalizeProjectIconColor = (value) => {
|
|
|
|
|
if (typeof value !== 'string') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const normalized = value.trim();
|
|
|
|
|
if (!projectIconHexColorPattern.test(normalized)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return normalized;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const applyProjectIconSvgTheme = (svgMarkup, themeVariant, iconColor) => {
|
|
|
|
|
if (typeof svgMarkup !== 'string') {
|
|
|
|
|
return svgMarkup;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const color = iconColor || projectIconThemeColors[themeVariant];
|
|
|
|
|
if (!color) {
|
|
|
|
|
return svgMarkup;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const svgTagIndex = svgMarkup.search(/<svg\b/i);
|
|
|
|
|
if (svgTagIndex === -1) {
|
|
|
|
|
return svgMarkup;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const svgOpenTagEndIndex = svgMarkup.indexOf('>', svgTagIndex);
|
|
|
|
|
if (svgOpenTagEndIndex === -1) {
|
|
|
|
|
return svgMarkup;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const overrideStyle = `<style data-openchamber-theme-icon="1">:root{color:${color}!important;}</style>`;
|
|
|
|
|
return `${svgMarkup.slice(0, svgOpenTagEndIndex + 1)}${overrideStyle}${svgMarkup.slice(svgOpenTagEndIndex + 1)}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const findProjectById = (settings, projectId) => {
|
|
|
|
|
const projects = sanitizeProjects(settings?.projects) || [];
|
|
|
|
|
const index = projects.findIndex((project) => project.id === projectId);
|
|
|
|
|
if (index === -1) {
|
|
|
|
|
return { projects, index: -1, project: null };
|
|
|
|
|
}
|
|
|
|
|
return { projects, index, project: projects[index] };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fsSearchRuntime = createFsSearchRuntime({
|
|
|
|
|
fsPromises,
|
|
|
|
|
path,
|
|
|
|
|
spawn,
|
|
|
|
|
resolveGitBinaryForSpawn,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/api/projects/:projectId/icon', async (req, res) => {
|
|
|
|
|
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : '';
|
|
|
|
|
if (!projectId) {
|
|
|
|
|
return res.status(400).json({ error: 'projectId is required' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const settings = await readSettingsFromDiskMigrated();
|
|
|
|
|
const { project } = findProjectById(settings, projectId);
|
|
|
|
|
if (!project) {
|
|
|
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const metadataMime = normalizeProjectIconMime(project.iconImage?.mime);
|
|
|
|
|
const preferredPath = metadataMime ? projectIconPathForMime(projectId, metadataMime) : null;
|
|
|
|
|
const candidates = preferredPath
|
|
|
|
|
? [preferredPath, ...projectIconPathCandidates(projectId).filter((candidate) => candidate !== preferredPath)]
|
|
|
|
|
: projectIconPathCandidates(projectId);
|
|
|
|
|
|
|
|
|
|
const themeQuery = Array.isArray(req.query?.theme) ? req.query.theme[0] : req.query?.theme;
|
|
|
|
|
const requestedThemeVariant = normalizeProjectIconThemeVariant(themeQuery);
|
|
|
|
|
const iconColorQuery = Array.isArray(req.query?.iconColor) ? req.query.iconColor[0] : req.query?.iconColor;
|
|
|
|
|
const requestedIconColor = normalizeProjectIconColor(iconColorQuery);
|
|
|
|
|
|
|
|
|
|
for (const iconPath of candidates) {
|
|
|
|
|
try {
|
|
|
|
|
const data = await fsPromises.readFile(iconPath);
|
|
|
|
|
const ext = path.extname(iconPath).slice(1).toLowerCase();
|
2026-05-12 04:10:39 -04:00
|
|
|
const resolvedMime = iconPath === preferredPath && metadataMime
|
|
|
|
|
? metadataMime
|
|
|
|
|
: projectIconExtensionToMime[ext] || 'application/octet-stream';
|
2026-03-31 18:47:00 +03:00
|
|
|
const contentType = resolvedMime === 'image/svg+xml' ? 'image/svg+xml; charset=utf-8' : resolvedMime;
|
|
|
|
|
|
|
|
|
|
if (resolvedMime === 'image/svg+xml' && requestedThemeVariant) {
|
|
|
|
|
const svgMarkup = data.toString('utf8');
|
|
|
|
|
const themedSvgMarkup = applyProjectIconSvgTheme(svgMarkup, requestedThemeVariant, requestedIconColor);
|
|
|
|
|
res.setHeader('Content-Type', contentType);
|
|
|
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
|
|
|
return res.send(themedSvgMarkup);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (resolvedMime === 'image/svg+xml' && requestedIconColor) {
|
|
|
|
|
const svgMarkup = data.toString('utf8');
|
|
|
|
|
const themedSvgMarkup = applyProjectIconSvgTheme(svgMarkup, requestedThemeVariant, requestedIconColor);
|
|
|
|
|
res.setHeader('Content-Type', contentType);
|
|
|
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
|
|
|
return res.send(themedSvgMarkup);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.setHeader('Content-Type', contentType);
|
|
|
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
|
|
|
return res.send(data);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (!error || typeof error !== 'object' || error.code !== 'ENOENT') {
|
|
|
|
|
console.warn('Failed to read project icon:', error);
|
|
|
|
|
return res.status(500).json({ error: 'Failed to read project icon' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return res.status(404).json({ error: 'Project icon not found' });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Failed to load project icon:', error);
|
|
|
|
|
return res.status(500).json({ error: 'Failed to load project icon' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.put('/api/projects/:projectId/icon', async (req, res) => {
|
|
|
|
|
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : '';
|
|
|
|
|
if (!projectId) {
|
|
|
|
|
return res.status(400).json({ error: 'projectId is required' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parsed = parseProjectIconDataUrl(req.body?.dataUrl);
|
|
|
|
|
if (!parsed.ok) {
|
|
|
|
|
return res.status(400).json({ error: parsed.error });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const settings = await readSettingsFromDiskMigrated();
|
|
|
|
|
const { projects, project } = findProjectById(settings, projectId);
|
|
|
|
|
if (!project) {
|
|
|
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const iconPath = projectIconPathForMime(projectId, parsed.mime);
|
|
|
|
|
if (!iconPath) {
|
|
|
|
|
return res.status(400).json({ error: 'Unsupported icon format' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await fsPromises.mkdir(projectIconsDirPath, { recursive: true });
|
|
|
|
|
await fsPromises.writeFile(iconPath, parsed.bytes);
|
|
|
|
|
await removeProjectIconFiles(projectId, iconPath);
|
|
|
|
|
|
|
|
|
|
const updatedAt = Date.now();
|
|
|
|
|
const nextProjects = projects.map((entry) => (
|
|
|
|
|
entry.id === projectId
|
|
|
|
|
? { ...entry, iconImage: { mime: parsed.mime, updatedAt, source: 'custom' } }
|
|
|
|
|
: entry
|
|
|
|
|
));
|
|
|
|
|
const updatedSettings = await persistSettings({ projects: nextProjects });
|
|
|
|
|
const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null;
|
|
|
|
|
|
|
|
|
|
return res.json({ project: updatedProject, settings: updatedSettings });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Failed to upload project icon:', error);
|
|
|
|
|
return res.status(500).json({ error: 'Failed to upload project icon' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.delete('/api/projects/:projectId/icon', async (req, res) => {
|
|
|
|
|
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : '';
|
|
|
|
|
if (!projectId) {
|
|
|
|
|
return res.status(400).json({ error: 'projectId is required' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const settings = await readSettingsFromDiskMigrated();
|
|
|
|
|
const { projects, project } = findProjectById(settings, projectId);
|
|
|
|
|
if (!project) {
|
|
|
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await removeProjectIconFiles(projectId);
|
|
|
|
|
|
|
|
|
|
const nextProjects = projects.map((entry) => (
|
|
|
|
|
entry.id === projectId
|
|
|
|
|
? { ...entry, iconImage: null }
|
|
|
|
|
: entry
|
|
|
|
|
));
|
|
|
|
|
const updatedSettings = await persistSettings({ projects: nextProjects });
|
|
|
|
|
const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null;
|
|
|
|
|
|
|
|
|
|
return res.json({ project: updatedProject, settings: updatedSettings });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Failed to remove project icon:', error);
|
|
|
|
|
return res.status(500).json({ error: 'Failed to remove project icon' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/api/projects/:projectId/icon/discover', async (req, res) => {
|
|
|
|
|
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : '';
|
|
|
|
|
if (!projectId) {
|
|
|
|
|
return res.status(400).json({ error: 'projectId is required' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const settings = await readSettingsFromDiskMigrated();
|
|
|
|
|
const { projects, project } = findProjectById(settings, projectId);
|
|
|
|
|
if (!project) {
|
|
|
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const force = req.body?.force === true;
|
|
|
|
|
if (project.iconImage?.source === 'custom' && !force) {
|
|
|
|
|
return res.json({
|
|
|
|
|
project,
|
|
|
|
|
skipped: true,
|
|
|
|
|
reason: 'custom-icon-present',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const faviconCandidates = await fsSearchRuntime.searchFilesystemFiles(project.path, {
|
|
|
|
|
limit: 200,
|
|
|
|
|
query: 'favicon',
|
|
|
|
|
includeHidden: true,
|
|
|
|
|
respectGitignore: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const filtered = faviconCandidates
|
|
|
|
|
.filter((entry) => /(^|\/)favicon\.(ico|png|svg|jpg|jpeg|webp)$/i.test(entry.path))
|
|
|
|
|
.sort((a, b) => a.path.length - b.path.length);
|
|
|
|
|
|
|
|
|
|
const selected = filtered[0];
|
|
|
|
|
if (!selected) {
|
|
|
|
|
return res.status(404).json({ error: 'No favicon found in project' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ext = path.extname(selected.path).slice(1).toLowerCase();
|
|
|
|
|
const mime = projectIconExtensionToMime[ext] || null;
|
|
|
|
|
if (!mime) {
|
|
|
|
|
return res.status(415).json({ error: 'Unsupported favicon format' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bytes = await fsPromises.readFile(selected.path);
|
|
|
|
|
if (bytes.length === 0) {
|
|
|
|
|
return res.status(400).json({ error: 'Discovered icon is empty' });
|
|
|
|
|
}
|
|
|
|
|
if (bytes.length > projectIconMaxBytes) {
|
|
|
|
|
return res.status(400).json({ error: 'Discovered icon exceeds size limit (5 MB)' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const iconPath = projectIconPathForMime(projectId, mime);
|
|
|
|
|
if (!iconPath) {
|
|
|
|
|
return res.status(415).json({ error: 'Unsupported favicon format' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await fsPromises.mkdir(projectIconsDirPath, { recursive: true });
|
|
|
|
|
await fsPromises.writeFile(iconPath, bytes);
|
|
|
|
|
await removeProjectIconFiles(projectId, iconPath);
|
|
|
|
|
|
|
|
|
|
const updatedAt = Date.now();
|
|
|
|
|
const nextProjects = projects.map((entry) => (
|
|
|
|
|
entry.id === projectId
|
|
|
|
|
? { ...entry, iconImage: { mime, updatedAt, source: 'auto' } }
|
|
|
|
|
: entry
|
|
|
|
|
));
|
|
|
|
|
const updatedSettings = await persistSettings({ projects: nextProjects });
|
|
|
|
|
const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null;
|
|
|
|
|
|
|
|
|
|
return res.json({
|
|
|
|
|
project: updatedProject,
|
|
|
|
|
settings: updatedSettings,
|
|
|
|
|
discoveredPath: selected.path,
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Failed to discover project icon:', error);
|
|
|
|
|
return res.status(500).json({ error: 'Failed to discover project icon' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|