ima2-gen 1.0.4 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ima2.js +26 -10
- package/bin/lib/platform.js +8 -0
- package/lib/assetLifecycle.js +120 -0
- package/lib/codexDetect.js +69 -0
- package/package.json +3 -2
- package/server.js +93 -5
- package/ui/dist/assets/index-BlTTpUh8.js +16 -0
- package/ui/dist/assets/index-BlTTpUh8.js.map +1 -0
- package/ui/dist/assets/index-fsgUenJk.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-1wzizazR.css +0 -1
- package/ui/dist/assets/index-C7SQ3J8h.js +0 -16
- package/ui/dist/assets/index-C7SQ3J8h.js.map +0 -1
package/bin/ima2.js
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "url";
|
|
|
6
6
|
import { spawn, execSync } from "child_process";
|
|
7
7
|
import { networkInterfaces, homedir } from "os";
|
|
8
8
|
import { openUrl, resolveBin } from "./lib/platform.js";
|
|
9
|
+
import { detectCodexAuth } from "../lib/codexDetect.js";
|
|
9
10
|
|
|
10
11
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
12
|
const ROOT = join(__dirname, "..");
|
|
@@ -67,12 +68,16 @@ async function setup() {
|
|
|
67
68
|
saveConfig(config);
|
|
68
69
|
console.log("\n Starting OAuth login...\n");
|
|
69
70
|
|
|
70
|
-
// Check if codex auth exists
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
existsSync(join(HOME, ".chatgpt-local", "auth.json"));
|
|
71
|
+
// Check if codex auth exists (file OR keyring via `codex login status`)
|
|
72
|
+
const auth = detectCodexAuth();
|
|
73
|
+
const hasAuth = auth.authed;
|
|
74
74
|
|
|
75
75
|
if (!hasAuth) {
|
|
76
|
+
if (auth.platform === "win32") {
|
|
77
|
+
console.log(
|
|
78
|
+
" Windows note: OpenAI Codex has no documented native installer. Use WSL2 for best results.\n",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
76
81
|
console.log(" Running 'codex login' — follow the browser prompt.\n");
|
|
77
82
|
try {
|
|
78
83
|
execSync(`${resolveBin("npx")} @openai/codex login`, { stdio: "inherit" });
|
|
@@ -82,7 +87,8 @@ async function setup() {
|
|
|
82
87
|
process.exit(1);
|
|
83
88
|
}
|
|
84
89
|
} else {
|
|
85
|
-
|
|
90
|
+
const how = auth.probe === "authed" ? "codex CLI" : "auth file";
|
|
91
|
+
console.log(` Existing OAuth session found (${how}).\n`);
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
saveConfig(config);
|
|
@@ -156,12 +162,22 @@ async function showStatus() {
|
|
|
156
162
|
console.log(" Run 'ima2 setup' to configure.\n");
|
|
157
163
|
}
|
|
158
164
|
|
|
159
|
-
// Check OAuth auth files
|
|
160
|
-
const
|
|
161
|
-
const hasChatgptAuth = existsSync(join(HOME, ".chatgpt-local", "auth.json"));
|
|
165
|
+
// Check OAuth auth files + codex CLI probe
|
|
166
|
+
const auth = detectCodexAuth();
|
|
162
167
|
console.log(` OAuth sessions:`);
|
|
163
|
-
console.log(`
|
|
164
|
-
console.log(`
|
|
168
|
+
console.log(` ${auth.files.codex} ${auth.fileHits.codex ? "✓" : "✗"}`);
|
|
169
|
+
console.log(` ${auth.files.chatgpt} ${auth.fileHits.chatgpt ? "✓" : "✗"}`);
|
|
170
|
+
if (auth.fileHits.xdgCodex) {
|
|
171
|
+
console.log(` ${auth.files.xdgCodex} ✓`);
|
|
172
|
+
}
|
|
173
|
+
const probeLabel =
|
|
174
|
+
auth.probe === "authed" ? "✓ authed"
|
|
175
|
+
: auth.probe === "unauthed" ? "✗ not logged in"
|
|
176
|
+
: "– codex CLI not found";
|
|
177
|
+
console.log(` codex login status ${probeLabel}`);
|
|
178
|
+
if (auth.platform === "win32") {
|
|
179
|
+
console.log(" (Windows: no native codex installer — use WSL2)");
|
|
180
|
+
}
|
|
165
181
|
console.log("");
|
|
166
182
|
}
|
|
167
183
|
|
package/bin/lib/platform.js
CHANGED
|
@@ -37,6 +37,14 @@ export function resolveBin(name) {
|
|
|
37
37
|
* spawn() wrapper that works for npm/npx/any PATH-resolved exe on Windows.
|
|
38
38
|
*/
|
|
39
39
|
export function spawnBin(name, args, opts = {}) {
|
|
40
|
+
if (isWin) {
|
|
41
|
+
// Node 24 on Windows can throw EINVAL when spawning PATH-resolved .cmd
|
|
42
|
+
// shims directly with piped stdio. Routing through cmd.exe avoids that.
|
|
43
|
+
return spawn("cmd.exe", ["/d", "/s", "/c", `${name} ${args.join(" ")}`], {
|
|
44
|
+
windowsHide: true,
|
|
45
|
+
...opts,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
40
48
|
return spawn(resolveBin(name), args, { windowsHide: true, ...opts });
|
|
41
49
|
}
|
|
42
50
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { getDb } from "./db.js";
|
|
2
|
+
import { rename, unlink, mkdir, access } from "fs/promises";
|
|
3
|
+
import { join, resolve, sep } from "path";
|
|
4
|
+
|
|
5
|
+
const DIR = "generated";
|
|
6
|
+
const TRASH = ".trash";
|
|
7
|
+
const TRASH_TTL_MS = 10_000;
|
|
8
|
+
|
|
9
|
+
function resolveInGenerated(rootDir, relPath) {
|
|
10
|
+
if (typeof relPath !== "string" || relPath.length === 0) {
|
|
11
|
+
const err = new Error("filename required");
|
|
12
|
+
err.status = 400;
|
|
13
|
+
err.code = "INVALID_FILENAME";
|
|
14
|
+
throw err;
|
|
15
|
+
}
|
|
16
|
+
if (relPath.includes("\0")) {
|
|
17
|
+
const err = new Error("invalid filename");
|
|
18
|
+
err.status = 400;
|
|
19
|
+
err.code = "INVALID_FILENAME";
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
22
|
+
const baseDir = resolve(rootDir, DIR);
|
|
23
|
+
const target = resolve(baseDir, relPath);
|
|
24
|
+
if (target !== baseDir && !target.startsWith(baseDir + sep)) {
|
|
25
|
+
const err = new Error("filename escapes generated/");
|
|
26
|
+
err.status = 400;
|
|
27
|
+
err.code = "INVALID_FILENAME";
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
return target;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function nodesReferencingFilename(filename) {
|
|
34
|
+
// The client stores imageUrl as `/generated/<encoded filename>` in node data JSON.
|
|
35
|
+
// We scan all sessions' nodes for substring match on the decoded and encoded forms.
|
|
36
|
+
const db = getDb();
|
|
37
|
+
const encoded = encodeURIComponent(filename);
|
|
38
|
+
const rows = db
|
|
39
|
+
.prepare("SELECT session_id AS sessionId, id, data FROM nodes WHERE data LIKE ? OR data LIKE ?")
|
|
40
|
+
.all(`%${filename}%`, `%${encoded}%`);
|
|
41
|
+
return rows;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function markNodesAssetMissing(filename) {
|
|
45
|
+
const db = getDb();
|
|
46
|
+
const rows = nodesReferencingFilename(filename);
|
|
47
|
+
if (rows.length === 0) return { sessionsTouched: 0, nodesTouched: 0 };
|
|
48
|
+
const touchedSessions = new Set();
|
|
49
|
+
const update = db.prepare("UPDATE nodes SET data = ? WHERE session_id = ? AND id = ?");
|
|
50
|
+
const bumpSession = db.prepare("UPDATE sessions SET graph_version = graph_version + 1, updated_at = ? WHERE id = ?");
|
|
51
|
+
const tx = db.transaction(() => {
|
|
52
|
+
for (const r of rows) {
|
|
53
|
+
let data;
|
|
54
|
+
try { data = JSON.parse(r.data); } catch { data = {}; }
|
|
55
|
+
const imgRef = data?.imageUrl || "";
|
|
56
|
+
if (imgRef.includes(filename) || imgRef.includes(encodeURIComponent(filename))) {
|
|
57
|
+
data.imageUrl = null;
|
|
58
|
+
data.status = "asset-missing";
|
|
59
|
+
update.run(JSON.stringify(data), r.sessionId, r.id);
|
|
60
|
+
touchedSessions.add(r.sessionId);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const t = Date.now();
|
|
64
|
+
for (const sid of touchedSessions) bumpSession.run(t, sid);
|
|
65
|
+
});
|
|
66
|
+
tx();
|
|
67
|
+
return { sessionsTouched: touchedSessions.size, nodesTouched: rows.length };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function trashAsset(rootDir, filename) {
|
|
71
|
+
const src = resolveInGenerated(rootDir, filename);
|
|
72
|
+
try {
|
|
73
|
+
await access(src);
|
|
74
|
+
} catch {
|
|
75
|
+
const err = new Error("Asset not found");
|
|
76
|
+
err.status = 404;
|
|
77
|
+
err.code = "ASSET_NOT_FOUND";
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
const trashDir = resolve(rootDir, DIR, TRASH);
|
|
81
|
+
await mkdir(trashDir, { recursive: true });
|
|
82
|
+
// Flatten filename (subdir separators -> __) so trash is flat & easy to restore
|
|
83
|
+
const flat = filename.replace(/[\\/]+/g, "__");
|
|
84
|
+
const trashPath = join(trashDir, `${Date.now()}_${flat}`);
|
|
85
|
+
await rename(src, trashPath);
|
|
86
|
+
// Move sidecar too (best-effort)
|
|
87
|
+
await rename(src + ".json", trashPath + ".json").catch(() => {});
|
|
88
|
+
|
|
89
|
+
const summary = markNodesAssetMissing(filename);
|
|
90
|
+
|
|
91
|
+
// Schedule hard delete after TTL
|
|
92
|
+
const unlinkAt = Date.now() + TRASH_TTL_MS;
|
|
93
|
+
setTimeout(async () => {
|
|
94
|
+
await unlink(trashPath).catch(() => {});
|
|
95
|
+
await unlink(trashPath + ".json").catch(() => {});
|
|
96
|
+
}, TRASH_TTL_MS).unref?.();
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
trashId: trashPath.slice(trashDir.length + 1),
|
|
101
|
+
filename,
|
|
102
|
+
unlinkAt,
|
|
103
|
+
sessionsTouched: summary.sessionsTouched,
|
|
104
|
+
nodesTouched: summary.nodesTouched,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function restoreAsset(rootDir, trashId, originalFilename) {
|
|
109
|
+
const trashDir = resolve(rootDir, DIR, TRASH);
|
|
110
|
+
const src = resolve(trashDir, trashId);
|
|
111
|
+
if (!src.startsWith(trashDir + sep) && src !== trashDir) {
|
|
112
|
+
const err = new Error("invalid trashId");
|
|
113
|
+
err.status = 400;
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
const dst = resolveInGenerated(rootDir, originalFilename);
|
|
117
|
+
await rename(src, dst);
|
|
118
|
+
await rename(src + ".json", dst + ".json").catch(() => {});
|
|
119
|
+
return { ok: true };
|
|
120
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Codex CLI / OAuth auth detection across platforms.
|
|
2
|
+
// References:
|
|
3
|
+
// - OpenAI Codex stores auth under CODEX_HOME (default ~/.codex/auth.json).
|
|
4
|
+
// - Legacy chatgpt-local stores auth under ~/.chatgpt-local/auth.json.
|
|
5
|
+
// - Auth may live in OS keyring instead of a file (file absence ≠ unauth).
|
|
6
|
+
// - Windows has no documented native install path; WSL is the supported path.
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
const HOME = homedir();
|
|
13
|
+
|
|
14
|
+
export function codexAuthPaths() {
|
|
15
|
+
const codexHome = process.env.CODEX_HOME || join(HOME, ".codex");
|
|
16
|
+
return {
|
|
17
|
+
codex: join(codexHome, "auth.json"),
|
|
18
|
+
chatgpt: join(HOME, ".chatgpt-local", "auth.json"),
|
|
19
|
+
xdgCodex: join(HOME, ".config", "codex", "auth.json"),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function hasAuthFile() {
|
|
24
|
+
const p = codexAuthPaths();
|
|
25
|
+
return existsSync(p.codex) || existsSync(p.chatgpt) || existsSync(p.xdgCodex);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Non-invasive probe: `codex login status` returns 0 when authed (file OR keyring).
|
|
29
|
+
// Returns: "authed" | "unauthed" | "missing" (codex binary not found)
|
|
30
|
+
export function codexLoginStatus(timeoutMs = 2000) {
|
|
31
|
+
const candidates =
|
|
32
|
+
process.platform === "win32"
|
|
33
|
+
? ["codex.cmd", "codex.exe", "codex"]
|
|
34
|
+
: ["codex"];
|
|
35
|
+
for (const bin of candidates) {
|
|
36
|
+
try {
|
|
37
|
+
execFileSync(bin, ["login", "status"], {
|
|
38
|
+
stdio: "ignore",
|
|
39
|
+
timeout: timeoutMs,
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
});
|
|
42
|
+
return "authed";
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err && err.code === "ENOENT") continue;
|
|
45
|
+
// non-zero exit = binary exists but not authed
|
|
46
|
+
if (err && typeof err.status === "number") return "unauthed";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return "missing";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function detectCodexAuth() {
|
|
53
|
+
const files = codexAuthPaths();
|
|
54
|
+
const fileHits = {
|
|
55
|
+
codex: existsSync(files.codex),
|
|
56
|
+
chatgpt: existsSync(files.chatgpt),
|
|
57
|
+
xdgCodex: existsSync(files.xdgCodex),
|
|
58
|
+
};
|
|
59
|
+
const probe = codexLoginStatus();
|
|
60
|
+
const authed = probe === "authed" || fileHits.codex || fileHits.chatgpt || fileHits.xdgCodex;
|
|
61
|
+
return {
|
|
62
|
+
authed,
|
|
63
|
+
probe,
|
|
64
|
+
files,
|
|
65
|
+
fileHits,
|
|
66
|
+
platform: process.platform,
|
|
67
|
+
wslHint: process.platform === "win32",
|
|
68
|
+
};
|
|
69
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ima2-gen",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "GPT Image 2 generator with OAuth & API key support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/ima2.js serve",
|
|
11
|
-
"dev": "node
|
|
11
|
+
"dev": "node scripts/dev.mjs",
|
|
12
|
+
"dev:server": "node --watch server.js",
|
|
12
13
|
"ui:install": "cd ui && npm install",
|
|
13
14
|
"ui:dev": "cd ui && npm run dev",
|
|
14
15
|
"ui:build": "cd ui && npm run build",
|
package/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import { spawn } from "child_process";
|
|
|
7
7
|
import { spawnBin, onShutdown } from "./bin/lib/platform.js";
|
|
8
8
|
import { existsSync, writeFileSync, unlinkSync, mkdirSync, readFileSync as fsReadFileSync } from "fs";
|
|
9
9
|
import { homedir } from "os";
|
|
10
|
+
import { randomBytes } from "crypto";
|
|
10
11
|
import { newNodeId, saveNode, loadNodeB64, loadNodeMeta, loadAssetB64 } from "./lib/nodeStore.js";
|
|
11
12
|
import { startJob, finishJob, listJobs, setJobPhase } from "./lib/inflight.js";
|
|
12
13
|
import {
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
saveGraph,
|
|
19
20
|
ensureDefaultSession,
|
|
20
21
|
} from "./lib/sessionStore.js";
|
|
22
|
+
import { trashAsset, restoreAsset } from "./lib/assetLifecycle.js";
|
|
21
23
|
|
|
22
24
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
25
|
const app = express();
|
|
@@ -278,6 +280,7 @@ async function listImages(baseDir) {
|
|
|
278
280
|
async function walk(dir, depth) {
|
|
279
281
|
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
280
282
|
for (const e of entries) {
|
|
283
|
+
if (e.name === ".trash") continue;
|
|
281
284
|
const full = join(dir, e.name);
|
|
282
285
|
if (e.isDirectory() && depth > 0) {
|
|
283
286
|
await walk(full, depth - 1);
|
|
@@ -294,7 +297,14 @@ app.get("/api/history", async (req, res) => {
|
|
|
294
297
|
try {
|
|
295
298
|
const dir = join(__dirname, "generated");
|
|
296
299
|
await mkdir(dir, { recursive: true });
|
|
297
|
-
const
|
|
300
|
+
const limitRaw = parseInt(req.query.limit);
|
|
301
|
+
const limit = Math.min(Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 50, 500);
|
|
302
|
+
const beforeTs = parseInt(req.query.before);
|
|
303
|
+
const beforeFn = typeof req.query.beforeFilename === "string" ? req.query.beforeFilename : null;
|
|
304
|
+
const sinceTs = parseInt(req.query.since);
|
|
305
|
+
const sessionId = typeof req.query.sessionId === "string" ? req.query.sessionId : null;
|
|
306
|
+
const groupBy = req.query.groupBy === "session" ? "session" : null;
|
|
307
|
+
|
|
298
308
|
const imgs = await listImages(dir);
|
|
299
309
|
const rows = await Promise.all(imgs.map(async ({ full, rel, name }) => {
|
|
300
310
|
const st = await stat(full).catch(() => null);
|
|
@@ -316,16 +326,91 @@ app.get("/api/history", async (req, res) => {
|
|
|
316
326
|
provider: meta?.provider || "oauth",
|
|
317
327
|
usage: meta?.usage || null,
|
|
318
328
|
webSearchCalls: meta?.webSearchCalls || 0,
|
|
329
|
+
sessionId: meta?.sessionId || null,
|
|
330
|
+
nodeId: meta?.nodeId || null,
|
|
331
|
+
parentNodeId: meta?.parentNodeId || null,
|
|
332
|
+
clientNodeId: meta?.clientNodeId || null,
|
|
333
|
+
kind: meta?.kind || null,
|
|
319
334
|
};
|
|
320
335
|
}));
|
|
321
|
-
|
|
322
|
-
|
|
336
|
+
|
|
337
|
+
// composite sort: createdAt DESC, filename DESC (stable tiebreaker)
|
|
338
|
+
rows.sort((a, b) => {
|
|
339
|
+
if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt;
|
|
340
|
+
return b.filename < a.filename ? -1 : b.filename > a.filename ? 1 : 0;
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
let filtered = rows;
|
|
344
|
+
if (Number.isFinite(sinceTs)) {
|
|
345
|
+
filtered = filtered.filter((r) => r.createdAt > sinceTs);
|
|
346
|
+
}
|
|
347
|
+
if (Number.isFinite(beforeTs)) {
|
|
348
|
+
filtered = filtered.filter((r) => {
|
|
349
|
+
if (r.createdAt < beforeTs) return true;
|
|
350
|
+
if (r.createdAt === beforeTs && beforeFn) return r.filename < beforeFn;
|
|
351
|
+
return false;
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
if (sessionId) {
|
|
355
|
+
filtered = filtered.filter((r) => r.sessionId === sessionId);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const page = filtered.slice(0, limit);
|
|
359
|
+
const nextCursor = page.length === limit && filtered.length > limit
|
|
360
|
+
? { before: page[page.length - 1].createdAt, beforeFilename: page[page.length - 1].filename }
|
|
361
|
+
: null;
|
|
362
|
+
|
|
363
|
+
if (groupBy === "session") {
|
|
364
|
+
// Group by sessionId while preserving createdAt DESC order overall.
|
|
365
|
+
const groups = new Map(); // sessionId|null -> { sessionId, items, lastUsedAt }
|
|
366
|
+
const loose = [];
|
|
367
|
+
for (const r of page) {
|
|
368
|
+
if (r.sessionId) {
|
|
369
|
+
let g = groups.get(r.sessionId);
|
|
370
|
+
if (!g) {
|
|
371
|
+
g = { sessionId: r.sessionId, items: [], lastUsedAt: r.createdAt };
|
|
372
|
+
groups.set(r.sessionId, g);
|
|
373
|
+
}
|
|
374
|
+
g.items.push(r);
|
|
375
|
+
if (r.createdAt > g.lastUsedAt) g.lastUsedAt = r.createdAt;
|
|
376
|
+
} else {
|
|
377
|
+
loose.push(r);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const sessions = Array.from(groups.values()).sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
381
|
+
return res.json({ sessions, loose, total: rows.length, nextCursor });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
res.json({ items: page, total: rows.length, nextCursor });
|
|
323
385
|
} catch (err) {
|
|
324
386
|
console.error("[history] error:", err.message);
|
|
325
387
|
res.status(500).json({ error: err.message });
|
|
326
388
|
}
|
|
327
389
|
});
|
|
328
390
|
|
|
391
|
+
// ── Asset lifecycle: soft-delete to .trash/, auto-purge after TTL ──
|
|
392
|
+
app.delete("/api/history/:filename", async (req, res) => {
|
|
393
|
+
try {
|
|
394
|
+
const filename = decodeURIComponent(req.params.filename);
|
|
395
|
+
const result = await trashAsset(__dirname, filename);
|
|
396
|
+
res.json(result);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
res.status(err.status || 500).json({ error: err.message, code: err.code });
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
app.post("/api/history/:filename/restore", async (req, res) => {
|
|
403
|
+
try {
|
|
404
|
+
const filename = decodeURIComponent(req.params.filename);
|
|
405
|
+
const trashId = typeof req.body?.trashId === "string" ? req.body.trashId : null;
|
|
406
|
+
if (!trashId) return res.status(400).json({ error: "trashId required" });
|
|
407
|
+
const result = await restoreAsset(__dirname, trashId, filename);
|
|
408
|
+
res.json(result);
|
|
409
|
+
} catch (err) {
|
|
410
|
+
res.status(err.status || 500).json({ error: err.message });
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
329
414
|
// ── OAuth status ──
|
|
330
415
|
app.get("/api/oauth/status", async (_req, res) => {
|
|
331
416
|
try {
|
|
@@ -433,7 +518,8 @@ app.post("/api/generate", async (req, res) => {
|
|
|
433
518
|
let totalWebSearchCalls = 0;
|
|
434
519
|
for (const r of results) {
|
|
435
520
|
if (r.status === "fulfilled" && r.value.b64) {
|
|
436
|
-
const
|
|
521
|
+
const rand = randomBytes(4).toString("hex");
|
|
522
|
+
const filename = `${Date.now()}_${rand}_${images.length}.${format}`;
|
|
437
523
|
await writeFile(join(__dirname, "generated", filename), Buffer.from(r.value.b64, "base64"));
|
|
438
524
|
// Sidecar metadata for /api/history reconstruction
|
|
439
525
|
const meta = {
|
|
@@ -581,7 +667,7 @@ app.post("/api/edit", async (req, res) => {
|
|
|
581
667
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
582
668
|
|
|
583
669
|
await mkdir(join(__dirname, "generated"), { recursive: true });
|
|
584
|
-
const filename = `${Date.now()}.png`;
|
|
670
|
+
const filename = `${Date.now()}_${randomBytes(4).toString("hex")}.png`;
|
|
585
671
|
await writeFile(join(__dirname, "generated", filename), Buffer.from(resultB64, "base64"));
|
|
586
672
|
const meta = {
|
|
587
673
|
prompt,
|
|
@@ -712,6 +798,8 @@ app.post("/api/node/generate", async (req, res) => {
|
|
|
712
798
|
const meta = {
|
|
713
799
|
nodeId,
|
|
714
800
|
parentNodeId,
|
|
801
|
+
sessionId,
|
|
802
|
+
clientNodeId,
|
|
715
803
|
prompt,
|
|
716
804
|
options: { quality, size, format },
|
|
717
805
|
createdAt: Date.now(),
|