| import crypto from "node:crypto"; |
| import fs from "node:fs/promises"; |
| import path from "node:path"; |
| import type { PluginLogger } from "openclaw/plugin-sdk/diffs"; |
| import type { DiffArtifactMeta, DiffOutputFormat } from "./types.js"; |
|
|
| const DEFAULT_TTL_MS = 30 * 60 * 1000; |
| const MAX_TTL_MS = 6 * 60 * 60 * 1000; |
| const SWEEP_FALLBACK_AGE_MS = 24 * 60 * 60 * 1000; |
| const DEFAULT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; |
| const VIEWER_PREFIX = "/plugins/diffs/view"; |
|
|
| type CreateArtifactParams = { |
| html: string; |
| title: string; |
| inputKind: DiffArtifactMeta["inputKind"]; |
| fileCount: number; |
| ttlMs?: number; |
| }; |
|
|
| type CreateStandaloneFileArtifactParams = { |
| format?: DiffOutputFormat; |
| ttlMs?: number; |
| }; |
|
|
| type StandaloneFileMeta = { |
| kind: "standalone_file"; |
| id: string; |
| createdAt: string; |
| expiresAt: string; |
| filePath: string; |
| }; |
|
|
| type ArtifactMetaFileName = "meta.json" | "file-meta.json"; |
|
|
| export class DiffArtifactStore { |
| private readonly rootDir: string; |
| private readonly logger?: PluginLogger; |
| private readonly cleanupIntervalMs: number; |
| private cleanupInFlight: Promise<void> | null = null; |
| private nextCleanupAt = 0; |
|
|
| constructor(params: { rootDir: string; logger?: PluginLogger; cleanupIntervalMs?: number }) { |
| this.rootDir = path.resolve(params.rootDir); |
| this.logger = params.logger; |
| this.cleanupIntervalMs = |
| params.cleanupIntervalMs === undefined |
| ? DEFAULT_CLEANUP_INTERVAL_MS |
| : Math.max(0, Math.floor(params.cleanupIntervalMs)); |
| } |
|
|
| async createArtifact(params: CreateArtifactParams): Promise<DiffArtifactMeta> { |
| await this.ensureRoot(); |
|
|
| const id = crypto.randomBytes(10).toString("hex"); |
| const token = crypto.randomBytes(24).toString("hex"); |
| const artifactDir = this.artifactDir(id); |
| const htmlPath = path.join(artifactDir, "viewer.html"); |
| const ttlMs = normalizeTtlMs(params.ttlMs); |
| const createdAt = new Date(); |
| const expiresAt = new Date(createdAt.getTime() + ttlMs); |
| const meta: DiffArtifactMeta = { |
| id, |
| token, |
| title: params.title, |
| inputKind: params.inputKind, |
| fileCount: params.fileCount, |
| createdAt: createdAt.toISOString(), |
| expiresAt: expiresAt.toISOString(), |
| viewerPath: `${VIEWER_PREFIX}/${id}/${token}`, |
| htmlPath, |
| }; |
|
|
| await fs.mkdir(artifactDir, { recursive: true }); |
| await fs.writeFile(htmlPath, params.html, "utf8"); |
| await this.writeMeta(meta); |
| this.scheduleCleanup(); |
| return meta; |
| } |
|
|
| async getArtifact(id: string, token: string): Promise<DiffArtifactMeta | null> { |
| const meta = await this.readMeta(id); |
| if (!meta) { |
| return null; |
| } |
| if (meta.token !== token) { |
| return null; |
| } |
| if (isExpired(meta)) { |
| await this.deleteArtifact(id); |
| return null; |
| } |
| return meta; |
| } |
|
|
| async readHtml(id: string): Promise<string> { |
| const meta = await this.readMeta(id); |
| if (!meta) { |
| throw new Error(`Diff artifact not found: ${id}`); |
| } |
| const htmlPath = this.normalizeStoredPath(meta.htmlPath, "htmlPath"); |
| return await fs.readFile(htmlPath, "utf8"); |
| } |
|
|
| async updateFilePath(id: string, filePath: string): Promise<DiffArtifactMeta> { |
| const meta = await this.readMeta(id); |
| if (!meta) { |
| throw new Error(`Diff artifact not found: ${id}`); |
| } |
| const normalizedFilePath = this.normalizeStoredPath(filePath, "filePath"); |
| const next: DiffArtifactMeta = { |
| ...meta, |
| filePath: normalizedFilePath, |
| imagePath: normalizedFilePath, |
| }; |
| await this.writeMeta(next); |
| return next; |
| } |
|
|
| async updateImagePath(id: string, imagePath: string): Promise<DiffArtifactMeta> { |
| return this.updateFilePath(id, imagePath); |
| } |
|
|
| allocateFilePath(id: string, format: DiffOutputFormat = "png"): string { |
| return path.join(this.artifactDir(id), `preview.${format}`); |
| } |
|
|
| async createStandaloneFileArtifact( |
| params: CreateStandaloneFileArtifactParams = {}, |
| ): Promise<{ id: string; filePath: string; expiresAt: string }> { |
| await this.ensureRoot(); |
|
|
| const id = crypto.randomBytes(10).toString("hex"); |
| const artifactDir = this.artifactDir(id); |
| const format = params.format ?? "png"; |
| const filePath = path.join(artifactDir, `preview.${format}`); |
| const ttlMs = normalizeTtlMs(params.ttlMs); |
| const createdAt = new Date(); |
| const expiresAt = new Date(createdAt.getTime() + ttlMs).toISOString(); |
| const meta: StandaloneFileMeta = { |
| kind: "standalone_file", |
| id, |
| createdAt: createdAt.toISOString(), |
| expiresAt, |
| filePath: this.normalizeStoredPath(filePath, "filePath"), |
| }; |
|
|
| await fs.mkdir(artifactDir, { recursive: true }); |
| await this.writeStandaloneMeta(meta); |
| this.scheduleCleanup(); |
| return { |
| id, |
| filePath: meta.filePath, |
| expiresAt: meta.expiresAt, |
| }; |
| } |
|
|
| allocateImagePath(id: string, format: DiffOutputFormat = "png"): string { |
| return this.allocateFilePath(id, format); |
| } |
|
|
| scheduleCleanup(): void { |
| this.maybeCleanupExpired(); |
| } |
|
|
| async cleanupExpired(): Promise<void> { |
| await this.ensureRoot(); |
| const entries = await fs.readdir(this.rootDir, { withFileTypes: true }).catch(() => []); |
| const now = Date.now(); |
|
|
| await Promise.all( |
| entries |
| .filter((entry) => entry.isDirectory()) |
| .map(async (entry) => { |
| const id = entry.name; |
| const meta = await this.readMeta(id); |
| if (meta) { |
| if (isExpired(meta)) { |
| await this.deleteArtifact(id); |
| } |
| return; |
| } |
|
|
| const standaloneMeta = await this.readStandaloneMeta(id); |
| if (standaloneMeta) { |
| if (isExpired(standaloneMeta)) { |
| await this.deleteArtifact(id); |
| } |
| return; |
| } |
|
|
| const artifactPath = this.artifactDir(id); |
| const stat = await fs.stat(artifactPath).catch(() => null); |
| if (!stat) { |
| return; |
| } |
| if (now - stat.mtimeMs > SWEEP_FALLBACK_AGE_MS) { |
| await this.deleteArtifact(id); |
| } |
| }), |
| ); |
| } |
|
|
| private async ensureRoot(): Promise<void> { |
| await fs.mkdir(this.rootDir, { recursive: true }); |
| } |
|
|
| private maybeCleanupExpired(): void { |
| const now = Date.now(); |
| if (this.cleanupInFlight || now < this.nextCleanupAt) { |
| return; |
| } |
|
|
| this.nextCleanupAt = now + this.cleanupIntervalMs; |
| const cleanupPromise = this.cleanupExpired() |
| .catch((error) => { |
| this.nextCleanupAt = 0; |
| this.logger?.warn(`Failed to clean expired diff artifacts: ${String(error)}`); |
| }) |
| .finally(() => { |
| if (this.cleanupInFlight === cleanupPromise) { |
| this.cleanupInFlight = null; |
| } |
| }); |
|
|
| this.cleanupInFlight = cleanupPromise; |
| } |
|
|
| private artifactDir(id: string): string { |
| return this.resolveWithinRoot(id); |
| } |
|
|
| private async writeMeta(meta: DiffArtifactMeta): Promise<void> { |
| await this.writeJsonMeta(meta.id, "meta.json", meta); |
| } |
|
|
| private async readMeta(id: string): Promise<DiffArtifactMeta | null> { |
| const parsed = await this.readJsonMeta(id, "meta.json", "diff artifact"); |
| if (!parsed) { |
| return null; |
| } |
| return parsed as DiffArtifactMeta; |
| } |
|
|
| private async writeStandaloneMeta(meta: StandaloneFileMeta): Promise<void> { |
| await this.writeJsonMeta(meta.id, "file-meta.json", meta); |
| } |
|
|
| private async readStandaloneMeta(id: string): Promise<StandaloneFileMeta | null> { |
| const parsed = await this.readJsonMeta(id, "file-meta.json", "standalone diff"); |
| if (!parsed) { |
| return null; |
| } |
| try { |
| const value = parsed as Partial<StandaloneFileMeta>; |
| if ( |
| value.kind !== "standalone_file" || |
| typeof value.id !== "string" || |
| typeof value.createdAt !== "string" || |
| typeof value.expiresAt !== "string" || |
| typeof value.filePath !== "string" |
| ) { |
| return null; |
| } |
| return { |
| kind: value.kind, |
| id: value.id, |
| createdAt: value.createdAt, |
| expiresAt: value.expiresAt, |
| filePath: this.normalizeStoredPath(value.filePath, "filePath"), |
| }; |
| } catch (error) { |
| this.logger?.warn(`Failed to normalize standalone diff metadata for ${id}: ${String(error)}`); |
| return null; |
| } |
| } |
|
|
| private metaFilePath(id: string, fileName: ArtifactMetaFileName): string { |
| return path.join(this.artifactDir(id), fileName); |
| } |
|
|
| private async writeJsonMeta( |
| id: string, |
| fileName: ArtifactMetaFileName, |
| data: unknown, |
| ): Promise<void> { |
| await fs.writeFile(this.metaFilePath(id, fileName), JSON.stringify(data, null, 2), "utf8"); |
| } |
|
|
| private async readJsonMeta( |
| id: string, |
| fileName: ArtifactMetaFileName, |
| context: string, |
| ): Promise<unknown | null> { |
| try { |
| const raw = await fs.readFile(this.metaFilePath(id, fileName), "utf8"); |
| return JSON.parse(raw) as unknown; |
| } catch (error) { |
| if (isFileNotFound(error)) { |
| return null; |
| } |
| this.logger?.warn(`Failed to read ${context} metadata for ${id}: ${String(error)}`); |
| return null; |
| } |
| } |
|
|
| private async deleteArtifact(id: string): Promise<void> { |
| await fs.rm(this.artifactDir(id), { recursive: true, force: true }).catch(() => {}); |
| } |
|
|
| private resolveWithinRoot(...parts: string[]): string { |
| const candidate = path.resolve(this.rootDir, ...parts); |
| this.assertWithinRoot(candidate); |
| return candidate; |
| } |
|
|
| private normalizeStoredPath(rawPath: string, label: string): string { |
| const candidate = path.isAbsolute(rawPath) |
| ? path.resolve(rawPath) |
| : path.resolve(this.rootDir, rawPath); |
| this.assertWithinRoot(candidate, label); |
| return candidate; |
| } |
|
|
| private assertWithinRoot(candidate: string, label = "path"): void { |
| const relative = path.relative(this.rootDir, candidate); |
| if ( |
| relative === "" || |
| (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)) |
| ) { |
| return; |
| } |
| throw new Error(`Diff artifact ${label} escapes store root: ${candidate}`); |
| } |
| } |
|
|
| function normalizeTtlMs(value?: number): number { |
| if (!Number.isFinite(value) || value === undefined) { |
| return DEFAULT_TTL_MS; |
| } |
| const rounded = Math.floor(value); |
| if (rounded <= 0) { |
| return DEFAULT_TTL_MS; |
| } |
| return Math.min(rounded, MAX_TTL_MS); |
| } |
|
|
| function isExpired(meta: { expiresAt: string }): boolean { |
| const expiresAt = Date.parse(meta.expiresAt); |
| if (!Number.isFinite(expiresAt)) { |
| return true; |
| } |
| return Date.now() >= expiresAt; |
| } |
|
|
| function isFileNotFound(error: unknown): boolean { |
| return error instanceof Error && "code" in error && error.code === "ENOENT"; |
| } |
|
|