Files
ccs/src/web-server/shared-routes.ts
T

663 lines
17 KiB
TypeScript

/**
* Shared Data Routes (Phase 07)
*
* API routes for commands, skills, agents from ~/.ccs/shared/
*/
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';
import { getCcsDir } from '../utils/config-manager';
import { getClaudeConfigDir } from '../utils/claude-config-path';
import { requireLocalAccessWhenAuthDisabled } from './middleware/auth-middleware';
export const sharedRoutes = Router();
sharedRoutes.use((req: Request, res: Response, next) => {
if (
requireLocalAccessWhenAuthDisabled(
req,
res,
'Shared-content endpoints require localhost access when dashboard auth is disabled.'
)
) {
next();
}
});
const MAX_DIRECTORY_TRAVERSAL_DEPTH = 10;
const MAX_DESCRIPTION_LENGTH = 140;
const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB
const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB
const SHARED_ITEMS_CACHE_TTL_MS = 1000;
type SharedCollectionType = 'commands' | 'skills' | 'agents';
interface SharedItem {
name: string;
description: string;
path: string;
type: 'command' | 'skill' | 'agent';
}
interface SharedItemsCacheEntry {
items: SharedItem[];
sharedDir: string;
expiresAt: number;
}
const sharedItemsCache = new Map<SharedCollectionType, SharedItemsCacheEntry>();
/**
* GET /api/shared/commands
*/
sharedRoutes.get('/commands', (_req: Request, res: Response) => {
const items = getSharedItems('commands');
res.json({ items });
});
/**
* GET /api/shared/skills
*/
sharedRoutes.get('/skills', (_req: Request, res: Response) => {
const items = getSharedItems('skills');
res.json({ items });
});
/**
* GET /api/shared/agents
*/
sharedRoutes.get('/agents', (_req: Request, res: Response) => {
const items = getSharedItems('agents');
res.json({ items });
});
/**
* GET /api/shared/content?type=commands|skills|agents&path=<item-path>
*/
sharedRoutes.get('/content', (req: Request, res: Response) => {
const typeParam = req.query.type;
const itemPathParam = req.query.path;
if (!isSharedCollectionType(typeParam)) {
res.status(400).json({ error: 'Invalid or missing type parameter' });
return;
}
if (typeof itemPathParam !== 'string' || itemPathParam.trim().length === 0) {
res.status(400).json({ error: 'Invalid or missing path parameter' });
return;
}
const ccsDir = getCcsDir();
const sharedDir = path.join(ccsDir, 'shared', typeParam);
if (!fs.existsSync(sharedDir)) {
res.status(404).json({ error: 'Shared directory not found' });
return;
}
const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir);
const allowedRoots = resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot);
const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots);
if (!contentResult) {
res.status(404).json({ error: 'Shared content not found' });
return;
}
res.json(contentResult);
});
/**
* GET /api/shared/summary
*/
sharedRoutes.get('/summary', (_req: Request, res: Response) => {
const commands = getSharedItems('commands').length;
const skills = getSharedItems('skills').length;
const agents = getSharedItems('agents').length;
res.json({
commands,
skills,
agents,
total: commands + skills + agents,
symlinkStatus: checkSymlinkStatus(),
});
});
function isSharedCollectionType(value: unknown): value is SharedCollectionType {
return value === 'commands' || value === 'skills' || value === 'agents';
}
function resolveAllowedRoots(
type: SharedCollectionType,
ccsDir: string,
sharedDirRoot: string
): Set<string> {
if (type === 'commands') {
return new Set<string>([sharedDirRoot]);
}
return new Set<string>([
sharedDirRoot,
...[
path.join(getClaudeConfigDir(), type),
path.join(ccsDir, '.claude', type),
path.join(ccsDir, 'shared', type),
]
.map((dirPath) => safeRealPath(dirPath))
.filter((dirPath): dirPath is string => typeof dirPath === 'string'),
]);
}
function getSharedItems(type: SharedCollectionType): SharedItem[] {
const ccsDir = getCcsDir();
const sharedDir = path.join(ccsDir, 'shared', type);
const now = Date.now();
if (!fs.existsSync(sharedDir)) {
sharedItemsCache.delete(type);
return [];
}
const cached = sharedItemsCache.get(type);
if (cached && cached.sharedDir === sharedDir && cached.expiresAt > now) {
return cached.items;
}
const items: SharedItem[] = [];
const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir);
const allowedRoots = resolveAllowedRoots(type, ccsDir, sharedDirRoot);
if (type === 'commands') {
const commandItems = getCommandItems(sharedDir, allowedRoots);
sharedItemsCache.set(type, {
items: commandItems,
sharedDir,
expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS,
});
return commandItems;
}
try {
const entries = fs.readdirSync(sharedDir, { withFileTypes: true });
for (const entry of entries) {
try {
const entryPath = path.join(sharedDir, entry.name);
const resolvedEntryPath = safeRealPath(entryPath);
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
continue;
}
const stats = fs.statSync(resolvedEntryPath);
const item = getSkillOrAgentItem(
type,
entry,
entryPath,
resolvedEntryPath,
allowedRoots,
stats
);
if (!item) {
continue;
}
items.push(item);
} catch {
// Fail soft per entry so one bad item does not hide valid results.
}
}
} catch {
// Directory read failed
}
const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name));
sharedItemsCache.set(type, {
items: sortedItems,
sharedDir,
expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS,
});
return sortedItems;
}
function getCommandItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
const markdownFiles = collectMarkdownFiles(sharedDir, allowedRoots);
const items: SharedItem[] = [];
for (const markdownFile of markdownFiles) {
const description = readMarkdownDescription(markdownFile.resolvedPath, allowedRoots);
if (!description) {
continue;
}
const relativePath = path.relative(sharedDir, markdownFile.displayPath);
const normalizedName = relativePath.split(path.sep).join('/').replace(/\.md$/i, '');
if (!normalizedName) {
continue;
}
items.push({
name: normalizedName,
description,
path: markdownFile.displayPath,
type: 'command',
});
}
return items.sort((a, b) => a.name.localeCompare(b.name));
}
function getSkillOrAgentItem(
type: 'skills' | 'agents',
entry: fs.Dirent,
entryPath: string,
resolvedEntryPath: string,
allowedRoots: Set<string>,
stats: fs.Stats
): SharedItem | null {
if (type === 'skills') {
if (!stats.isDirectory()) {
return null;
}
const description = readMarkdownDescription(
path.join(resolvedEntryPath, 'SKILL.md'),
allowedRoots
);
if (!description) {
return null;
}
return {
name: entry.name,
description,
path: entryPath,
type: 'skill',
};
}
if (stats.isDirectory()) {
const description = readFirstMarkdownDescription(
[
path.join(resolvedEntryPath, 'prompt.md'),
path.join(resolvedEntryPath, 'AGENT.md'),
path.join(resolvedEntryPath, 'agent.md'),
],
allowedRoots
);
if (!description) {
return null;
}
return {
name: entry.name,
description,
path: entryPath,
type: 'agent',
};
}
if (!stats.isFile() || !entry.name.toLowerCase().endsWith('.md')) {
return null;
}
const description = readMarkdownDescription(resolvedEntryPath, allowedRoots);
if (!description) {
return null;
}
return {
name: entry.name.replace(/\.md$/i, ''),
description,
path: entryPath,
type: 'agent',
};
}
interface MarkdownFileEntry {
displayPath: string;
resolvedPath: string;
}
function collectMarkdownFiles(sharedDir: string, allowedRoots: Set<string>): MarkdownFileEntry[] {
const directoriesToVisit: Array<{ path: string; depth: number }> = [
{ path: sharedDir, depth: 0 },
];
const visitedDirectories = new Set<string>();
const markdownFiles: MarkdownFileEntry[] = [];
while (directoriesToVisit.length > 0) {
const current = directoriesToVisit.pop();
if (!current) {
continue;
}
const currentDir = current.path;
const resolvedCurrentDir = safeRealPath(currentDir);
if (!resolvedCurrentDir || !isPathWithinAny(resolvedCurrentDir, allowedRoots)) {
continue;
}
const normalizedDirPath = normalizeForPathComparison(resolvedCurrentDir);
if (visitedDirectories.has(normalizedDirPath)) {
continue;
}
visitedDirectories.add(normalizedDirPath);
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(currentDir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const entryPath = path.join(currentDir, entry.name);
const resolvedEntryPath = safeRealPath(entryPath);
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
continue;
}
let stats: fs.Stats;
try {
stats = fs.statSync(resolvedEntryPath);
} catch {
continue;
}
if (stats.isDirectory()) {
if (current.depth < MAX_DIRECTORY_TRAVERSAL_DEPTH) {
directoriesToVisit.push({ path: entryPath, depth: current.depth + 1 });
}
continue;
}
if (stats.isFile() && entry.name.toLowerCase().endsWith('.md')) {
markdownFiles.push({
displayPath: entryPath,
resolvedPath: resolvedEntryPath,
});
}
}
}
return markdownFiles;
}
function extractDescription(content: string): string {
const frontmatterDescription = extractFrontmatterDescription(content);
if (frontmatterDescription) {
return trimDescription(frontmatterDescription);
}
// Extract first non-empty, non-heading line from the markdown body.
const lines = stripFrontmatter(content).split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (isDescriptionBodyLine(trimmed)) {
return trimDescription(trimmed);
}
}
return 'No description';
}
function isDescriptionBodyLine(line: string): boolean {
if (!line) {
return false;
}
if (line === '---' || line === '...') {
return false;
}
return !line.startsWith('#') && !line.startsWith('<!--');
}
function extractFrontmatterDescription(content: string): string | null {
const frontmatterMatch = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\s*\r?\n|$)/);
if (!frontmatterMatch) {
return null;
}
try {
const parsed = yaml.load(frontmatterMatch[1]) as Record<string, unknown> | null;
const description = parsed?.description;
if (typeof description !== 'string') {
return null;
}
const trimmed = description.trim();
return trimmed.length > 0 ? trimmed : null;
} catch {
return null;
}
}
function stripFrontmatter(content: string): string {
return content.replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*\r?\n?/, '');
}
function trimDescription(description: string): string {
if (description.length <= MAX_DESCRIPTION_LENGTH) {
return description;
}
return `${description.slice(0, MAX_DESCRIPTION_LENGTH - 3).trimEnd()}...`;
}
function readFirstMarkdownDescription(
markdownPaths: string[],
allowedRoots: Set<string>
): string | null {
for (const markdownPath of markdownPaths) {
const description = readMarkdownDescription(markdownPath, allowedRoots);
if (description) {
return description;
}
}
return null;
}
function readMarkdownDescription(markdownPath: string, allowedRoots: Set<string>): string | null {
try {
const resolvedMarkdownPath = safeRealPath(markdownPath);
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
return null;
}
const stats = fs.statSync(resolvedMarkdownPath);
if (!stats.isFile()) {
return null;
}
if (stats.size > MAX_MARKDOWN_FILE_BYTES) {
return null;
}
const content = fs.readFileSync(resolvedMarkdownPath, 'utf8');
return extractDescription(content);
} catch {
return null;
}
}
function readMarkdownContent(markdownPath: string, allowedRoots: Set<string>): string | null {
try {
const resolvedMarkdownPath = safeRealPath(markdownPath);
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
return null;
}
const stats = fs.statSync(resolvedMarkdownPath);
if (!stats.isFile()) {
return null;
}
if (stats.size > MAX_CONTENT_FILE_BYTES) {
return null;
}
return fs.readFileSync(resolvedMarkdownPath, 'utf8');
} catch {
return null;
}
}
function resolveReadableMarkdownPath(
markdownPaths: string[],
allowedRoots: Set<string>
): string | null {
for (const markdownPath of markdownPaths) {
const resolvedMarkdownPath = safeRealPath(markdownPath);
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
continue;
}
try {
const stats = fs.statSync(resolvedMarkdownPath);
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
continue;
}
return resolvedMarkdownPath;
} catch {
continue;
}
}
return null;
}
function getSharedItemContent(
type: SharedCollectionType,
itemPath: string,
allowedRoots: Set<string>
): { content: string; contentPath: string } | null {
const resolvedItemPath = safeRealPath(itemPath);
if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) {
return null;
}
let itemStats: fs.Stats;
try {
itemStats = fs.statSync(resolvedItemPath);
} catch {
return null;
}
let markdownPath: string | null = null;
if (type === 'commands') {
if (!itemStats.isFile() || !itemPath.toLowerCase().endsWith('.md')) {
return null;
}
markdownPath = resolvedItemPath;
} else if (type === 'skills') {
if (!itemStats.isDirectory()) {
return null;
}
markdownPath = resolveReadableMarkdownPath(
[path.join(resolvedItemPath, 'SKILL.md')],
allowedRoots
);
} else {
if (itemStats.isDirectory()) {
markdownPath = resolveReadableMarkdownPath(
[
path.join(resolvedItemPath, 'prompt.md'),
path.join(resolvedItemPath, 'AGENT.md'),
path.join(resolvedItemPath, 'agent.md'),
],
allowedRoots
);
} else if (itemStats.isFile() && itemPath.toLowerCase().endsWith('.md')) {
markdownPath = resolvedItemPath;
}
}
if (!markdownPath) {
return null;
}
const content = readMarkdownContent(markdownPath, allowedRoots);
if (!content) {
return null;
}
return {
content,
contentPath: markdownPath,
};
}
function safeRealPath(targetPath: string): string | null {
try {
return fs.realpathSync(targetPath);
} catch {
return null;
}
}
function isPathWithin(candidatePath: string, basePath: string): boolean {
const normalizedCandidate = normalizeForPathComparison(candidatePath);
const normalizedBase = normalizeForPathComparison(basePath);
const relative = path.relative(normalizedBase, normalizedCandidate);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
function isPathWithinAny(candidatePath: string, basePaths: Set<string>): boolean {
for (const basePath of basePaths) {
if (isPathWithin(candidatePath, basePath)) {
return true;
}
}
return false;
}
function normalizeForPathComparison(targetPath: string): string {
const normalized = path.resolve(targetPath);
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function checkSymlinkStatus(): { valid: boolean; message: string } {
const ccsDir = getCcsDir();
const sharedDir = path.join(ccsDir, 'shared');
if (!fs.existsSync(sharedDir)) {
return { valid: false, message: 'Shared directory not found' };
}
// Check all three symlinks: commands, skills, agents
const linkTypes = ['commands', 'skills', 'agents'];
let validLinks = 0;
for (const linkType of linkTypes) {
const linkPath = path.join(sharedDir, linkType);
try {
if (fs.existsSync(linkPath)) {
const stats = fs.lstatSync(linkPath);
if (stats.isSymbolicLink()) {
const target = fs.readlinkSync(linkPath);
// Check if it points to Claude config dir.
const expectedTarget = path.join(getClaudeConfigDir(), linkType);
if (path.resolve(path.dirname(linkPath), target) === path.resolve(expectedTarget)) {
validLinks++;
}
}
}
} catch {
// Not a symlink or read error
}
}
if (validLinks === linkTypes.length) {
return { valid: true, message: 'Symlinks active' };
} else if (validLinks > 0) {
return {
valid: false,
message: `Symlinks partially configured (${validLinks}/${linkTypes.length})`,
};
}
return { valid: false, message: 'Symlinks not configured' };
}