mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
Stacks on COD-38: accumulates a per-session attachment history and exposes it through a slide-in drawer with an unread badge, so attachments stay reachable after their cards are dismissed. Backend: - session-attachment-history: history state — dedupe by source path / relative path, newest-first, 100-item cap, and externalPath sanitization (the absolute host path is server-private and never leaves toState()). - session.ts: _attachmentHistory + getter (sanitized) / upsert / restore / getAttachmentHistoryForPersist; restored from saved state in the constructor. - file-routes: GET /attachments (list — resolves each entry to live metadata + routes; external entries are re-registered) and GET /attachments/:id (metadata poll). The by-id route guards via the registry's TOCTOU-safe resolveServableAttachmentPath. - server.ts: detected/registered attachments upsert into history and persist; the private (externalPath-bearing) history rides on disk under __attachmentHistory, separate from the sanitized public copy, and is restored on mux-session recovery. - types/session.ts: SessionAttachmentHistoryItem + SessionState.attachmentHistory. Frontend: - panels-ui: the drawer (lazy-built), unread badge, list render with per-item preview/download/open/"Card" (reshow) actions, and live refresh of the open drawer on new detections. - app.js: history state + per-session badge/cleanup wiring. - index.html / styles.css / mobile.css: header button + badge and the drawer. Verified: tsc / eslint / prettier / frontend-syntax / public-assets clean; new history-module unit tests pass; full test:ci green (2866 passed); badge, drawer open/render/reshow/close verified in-browser.
93 lines
3.2 KiB
TypeScript
93 lines
3.2 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { basename, extname } from 'node:path';
|
|
import type { SessionAttachmentHistoryItem } from './types/session.js';
|
|
import type { AttachmentDetectedEvent } from './types/tools.js';
|
|
import { getAttachmentType } from './attachment-registry.js';
|
|
|
|
export const ATTACHMENT_HISTORY_LIMIT = 100;
|
|
|
|
export interface ExternalAttachmentHistoryInput {
|
|
sessionId: string;
|
|
externalPath: string;
|
|
fileName?: string;
|
|
extension?: string;
|
|
size: number;
|
|
mtimeMs?: number;
|
|
timestamp?: number;
|
|
}
|
|
|
|
export function normalizeAttachmentExtension(extensionOrPath: string): string {
|
|
const value = extensionOrPath.startsWith('.') ? extensionOrPath : extname(extensionOrPath) || extensionOrPath;
|
|
return value.toLowerCase().replace(/^\./, '');
|
|
}
|
|
|
|
function historyKey(item: SessionAttachmentHistoryItem): string {
|
|
if (item.source === 'external' && item.externalPath) {
|
|
return `external:${item.externalPath}`;
|
|
}
|
|
return `detected:${item.relativePath || item.fileName}`;
|
|
}
|
|
|
|
function safeExternalHistoryId(item: SessionAttachmentHistoryItem): string {
|
|
const source = item.externalPath || item.id || item.fileName;
|
|
const digest = createHash('sha256').update(source).digest('hex').slice(0, 16);
|
|
return `external:${digest}:${item.fileName}`;
|
|
}
|
|
|
|
export function sanitizeAttachmentHistoryItem(item: SessionAttachmentHistoryItem): SessionAttachmentHistoryItem {
|
|
const { externalPath: _externalPath, ...safe } = item;
|
|
return {
|
|
...safe,
|
|
id: item.source === 'external' ? safeExternalHistoryId(item) : item.id,
|
|
};
|
|
}
|
|
|
|
export function sanitizeAttachmentHistory(
|
|
history: readonly SessionAttachmentHistoryItem[]
|
|
): SessionAttachmentHistoryItem[] {
|
|
return history.map(sanitizeAttachmentHistoryItem);
|
|
}
|
|
|
|
export function upsertAttachmentHistory(
|
|
history: readonly SessionAttachmentHistoryItem[],
|
|
item: SessionAttachmentHistoryItem
|
|
): SessionAttachmentHistoryItem[] {
|
|
const nextKey = historyKey(item);
|
|
return [item, ...history.filter((existing) => historyKey(existing) !== nextKey)].slice(0, ATTACHMENT_HISTORY_LIMIT);
|
|
}
|
|
|
|
export function buildDetectedAttachmentHistoryItem(event: AttachmentDetectedEvent): SessionAttachmentHistoryItem {
|
|
return {
|
|
id: `detected:${event.relativePath || event.fileName}`,
|
|
sessionId: event.sessionId,
|
|
fileName: event.fileName,
|
|
extension: normalizeAttachmentExtension(event.extension),
|
|
attachmentType: event.attachmentType,
|
|
size: event.size,
|
|
mtimeMs: 0,
|
|
timestamp: event.timestamp,
|
|
source: 'detected',
|
|
relativePath: event.relativePath,
|
|
};
|
|
}
|
|
|
|
export function buildExternalAttachmentHistoryItem(
|
|
input: ExternalAttachmentHistoryInput
|
|
): SessionAttachmentHistoryItem {
|
|
const extension = normalizeAttachmentExtension(input.extension || input.fileName || input.externalPath);
|
|
return {
|
|
id: `external:${createHash('sha256').update(input.externalPath).digest('hex').slice(0, 16)}:${
|
|
input.fileName || basename(input.externalPath)
|
|
}`,
|
|
sessionId: input.sessionId,
|
|
fileName: input.fileName || basename(input.externalPath),
|
|
extension,
|
|
attachmentType: getAttachmentType(extension),
|
|
size: input.size,
|
|
mtimeMs: input.mtimeMs ?? 0,
|
|
timestamp: input.timestamp ?? Date.now(),
|
|
source: 'external',
|
|
externalPath: input.externalPath,
|
|
};
|
|
}
|