@honlnk/image-studio-companion 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 鸿影
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,232 @@
1
+ # GPT Image Studio Companion CLI
2
+
3
+ 本地 CLI 助手,为 GPT Image Studio 网页端提供安全的 API 凭据代理服务。
4
+
5
+ ## 安装与运行
6
+
7
+ ### npm 安装
8
+
9
+ 推荐用户通过 npm 全局安装:
10
+
11
+ ```bash
12
+ npm install -g @honlnk/image-studio-companion
13
+ gpt-image-studio login
14
+ gpt-image-studio serve --channel stable
15
+ ```
16
+
17
+ 已安装 pnpm 的用户也可以使用:
18
+
19
+ ```bash
20
+ pnpm add -g @honlnk/image-studio-companion
21
+ gpt-image-studio login
22
+ gpt-image-studio serve --channel stable
23
+ ```
24
+
25
+ ### 从源码开发运行
26
+
27
+ 项目使用 pnpm workspace。在仓库根目录执行:
28
+
29
+ ```bash
30
+ pnpm install
31
+ pnpm dev:companion
32
+ ```
33
+
34
+ `pnpm dev:companion` 会以开发渠道启动服务,默认允许本地 Web App origin。
35
+
36
+ ### 从源码构建后运行
37
+
38
+ ```bash
39
+ pnpm --filter @honlnk/image-studio-companion build
40
+ pnpm --filter @honlnk/image-studio-companion start
41
+ ```
42
+
43
+ 也可以直接调用入口文件:
44
+
45
+ ```bash
46
+ npx tsx companion/src/main.ts serve --port 19750
47
+ ```
48
+
49
+ ### 生产渠道运行
50
+
51
+ 通过 npm 安装后,生产渠道默认只允许正式站点访问本地 companion:
52
+
53
+ ```bash
54
+ gpt-image-studio serve --channel stable
55
+ ```
56
+
57
+ 如需临时允许额外调试页面,必须显式提供完整 origin:
58
+
59
+ ```bash
60
+ gpt-image-studio serve --channel stable --allow-origin http://localhost:5173
61
+ ```
62
+
63
+ 启动后监听 `127.0.0.1:19750`,等待网页端发起配对连接。
64
+
65
+ ## 命令
66
+
67
+ ### `serve` — 启动本地服务
68
+
69
+ ```bash
70
+ gpt-image-studio serve
71
+ # 源码开发时也可以直接调用入口文件
72
+ npx tsx companion/src/main.ts serve
73
+ ```
74
+
75
+ 常用参数:
76
+
77
+ | 参数 | 说明 |
78
+ |------|------|
79
+ | `--port <port>` | 指定监听端口,默认 `19750` |
80
+ | `--channel stable|dev` | 指定安全渠道;stable 只允许正式站点,dev 额外允许本地开发 origin |
81
+ | `--allow-origin <origin...>` | 追加允许的完整 origin,不支持通配符 |
82
+ | `--session-ttl-days <days>` | 指定配对 session 有效天数,默认 30 天 |
83
+
84
+ ### `login` — 配置 API 凭据
85
+
86
+ ```bash
87
+ gpt-image-studio login
88
+ # 源码开发时
89
+ npx tsx companion/src/main.ts login
90
+ ```
91
+
92
+ 交互式输入:
93
+
94
+ 1. **API Base URL** — 回车使用默认值 `https://api.openai.com/v1/images`
95
+ 2. **API Key** — 输入时不回显
96
+
97
+ 凭据保存到 `~/.gpt-image-studio/credentials.json`。
98
+
99
+ ### `status` — 查看状态
100
+
101
+ ```bash
102
+ gpt-image-studio status
103
+ # 源码开发时
104
+ npx tsx companion/src/main.ts status
105
+ ```
106
+
107
+ 显示:
108
+ - 凭据配置情况(Base URL + 脱敏后的 API Key)
109
+ - 配对状态
110
+ - 服务是否运行
111
+
112
+ ### `logout` — 清除凭据
113
+
114
+ ```bash
115
+ gpt-image-studio logout
116
+ # 源码开发时
117
+ npx tsx companion/src/main.ts logout
118
+ ```
119
+
120
+ 删除本地保存的 API 凭据文件。
121
+
122
+ ### `unpair` — 清除网页端配对
123
+
124
+ ```bash
125
+ gpt-image-studio unpair
126
+ # 源码开发时
127
+ npx tsx companion/src/main.ts unpair
128
+ ```
129
+
130
+ 删除本地保存的配对 session,不会清除 API 凭据。
131
+
132
+ ## 配对流程
133
+
134
+ 1. 启动 companion 服务(`pnpm dev:companion`)
135
+ 2. 在网页端设置中切换到「本地 Companion」模式
136
+ 3. 点击配对,终端会显示 6 位配对码
137
+ 4. 在网页端输入配对码完成连接
138
+
139
+ 配对码有效期 5 分钟。配对成功后,session token 保存在 `~/.gpt-image-studio/session.json`,默认有效期 30 天,下次启动服务时自动恢复。可以通过 `--session-ttl-days` 调整有效天数。
140
+
141
+ ## 数据目录
142
+
143
+ 所有本地状态保存在 `~/.gpt-image-studio/`:
144
+
145
+ | 文件 | 内容 |
146
+ |------|------|
147
+ | `credentials.json` | API Base URL + API Key |
148
+ | `session.json` | 配对 session token |
149
+
150
+ ## 升级
151
+
152
+ ### npm 安装升级
153
+
154
+ 使用全局安装的用户可以通过同一包管理器升级:
155
+
156
+ ```bash
157
+ npm update -g @honlnk/image-studio-companion
158
+ # 或
159
+ pnpm update -g @honlnk/image-studio-companion
160
+ ```
161
+
162
+ 升级后建议运行:
163
+
164
+ ```bash
165
+ gpt-image-studio status
166
+ ```
167
+
168
+ ### 源码升级
169
+
170
+ 从源码升级时,在仓库根目录拉取最新代码并重新安装依赖:
171
+
172
+ ```bash
173
+ git pull
174
+ pnpm install
175
+ pnpm --filter @honlnk/image-studio-companion build
176
+ ```
177
+
178
+ 升级不会自动删除 `~/.gpt-image-studio/` 中的凭据和配对 session。升级后建议运行:
179
+
180
+ ```bash
181
+ npx tsx companion/src/main.ts status
182
+ ```
183
+
184
+ 如果服务端口、正式站点 origin 或 session 策略发生变化,重新启动 `serve` 后按网页端提示重新配对。
185
+
186
+ ## 卸载与清理
187
+
188
+ ### npm 卸载
189
+
190
+ 使用全局安装的用户可以执行:
191
+
192
+ ```bash
193
+ npm uninstall -g @honlnk/image-studio-companion
194
+ # 或
195
+ pnpm remove -g @honlnk/image-studio-companion
196
+ ```
197
+
198
+ 卸载 npm 包不会自动删除 `~/.gpt-image-studio/` 中的凭据和配对 session。
199
+
200
+ ### 本地状态清理
201
+
202
+ 停止 companion 进程后,可以按需要清理本地状态:
203
+
204
+ ```bash
205
+ # 只清除 API 凭据
206
+ gpt-image-studio logout
207
+ # 源码开发时
208
+ npx tsx companion/src/main.ts logout
209
+
210
+ # 只清除网页端配对 session
211
+ gpt-image-studio unpair
212
+ # 源码开发时
213
+ npx tsx companion/src/main.ts unpair
214
+
215
+ # 完整删除 companion 本地状态
216
+ rm -rf ~/.gpt-image-studio
217
+ ```
218
+
219
+ 如果只是切换 API key,运行 `login` 覆盖当前凭据即可,不需要删除整个目录。
220
+
221
+ ## 安全说明
222
+
223
+ - 服务仅监听 `127.0.0.1`,不对外暴露
224
+ - CORS 白名单默认只允许 `https://gpt-image.honlnk.com`
225
+ - `--channel dev` 会额外允许 `http://127.0.0.1:8888` 和 `http://localhost:8888`
226
+ - `--allow-origin` 只接受完整 origin,不支持通配符
227
+ - 非公开端点需要配对后的 Bearer token 鉴权
228
+ - 网页端无法读取真实 API Key,只能通过代理发起请求
229
+ - 代理请求会限制 body 大小、引用图片数量和图片 MIME 类型
230
+ - 日志会脱敏 Authorization、API key 和图片 base64 字段
231
+ - 凭据和 session 文件会以 `0600` 权限写入
232
+ - 凭据当前以明文 JSON 保存,请确保在个人设备上使用
@@ -0,0 +1,11 @@
1
+ type Credentials = {
2
+ apiBaseUrl: string;
3
+ apiKey: string;
4
+ savedAt: string;
5
+ };
6
+ export declare function loadCredentials(): Credentials | null;
7
+ export declare function saveCredentials(apiBaseUrl: string, apiKey: string): void;
8
+ export declare function clearCredentials(): void;
9
+ export declare function hasCredentials(): boolean;
10
+ export declare function maskApiKey(apiKey: string): string;
11
+ export {};
@@ -0,0 +1,42 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const CONFIG_DIR = join(homedir(), ".gpt-image-studio");
5
+ const CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
6
+ export function loadCredentials() {
7
+ try {
8
+ if (!existsSync(CREDENTIALS_FILE))
9
+ return null;
10
+ const data = JSON.parse(readFileSync(CREDENTIALS_FILE, "utf-8"));
11
+ if (!data.apiBaseUrl || !data.apiKey)
12
+ return null;
13
+ return data;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ export function saveCredentials(apiBaseUrl, apiKey) {
20
+ if (!existsSync(CONFIG_DIR)) {
21
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
22
+ }
23
+ const data = { apiBaseUrl, apiKey, savedAt: new Date().toISOString() };
24
+ writeFileSync(CREDENTIALS_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
25
+ chmodSync(CREDENTIALS_FILE, 0o600);
26
+ }
27
+ export function clearCredentials() {
28
+ try {
29
+ if (existsSync(CREDENTIALS_FILE)) {
30
+ unlinkSync(CREDENTIALS_FILE);
31
+ }
32
+ }
33
+ catch { }
34
+ }
35
+ export function hasCredentials() {
36
+ return loadCredentials() !== null;
37
+ }
38
+ export function maskApiKey(apiKey) {
39
+ if (apiKey.length <= 8)
40
+ return "***";
41
+ return apiKey.slice(0, 8) + "***";
42
+ }
package/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from "node:readline";
3
+ import { program } from "commander";
4
+ import { loadCredentials, saveCredentials, clearCredentials, maskApiKey } from "./credentials.js";
5
+ import { clearSession, getSessionInfo, loadSession } from "./pairingState.js";
6
+ import { createSecurityConfig } from "./securityConfig.js";
7
+ const VERSION = "0.1.0";
8
+ program
9
+ .name("gpt-image-studio")
10
+ .description("GPT Image Studio 本地 CLI Companion")
11
+ .version(VERSION);
12
+ program
13
+ .command("serve")
14
+ .description("启动本地 companion HTTP 服务")
15
+ .option("-p, --port <port>", "监听端口", "19750")
16
+ .option("--channel <channel>", "安全渠道:stable 或 dev", process.env.GPT_IMAGE_STUDIO_COMPANION_CHANNEL)
17
+ .option("--allow-origin <origin...>", "额外允许的完整 origin,例如 http://localhost:5173")
18
+ .option("--session-ttl-days <days>", "配对 session 有效天数", "30")
19
+ .action(async (opts) => {
20
+ const { startServer } = await import("./server.js");
21
+ await startServer({
22
+ port: Number(opts.port),
23
+ security: createSecurityConfig({
24
+ channel: opts.channel,
25
+ allowOrigins: opts.allowOrigin ?? [],
26
+ sessionTtlDays: Number(opts.sessionTtlDays),
27
+ }),
28
+ });
29
+ });
30
+ program
31
+ .command("login")
32
+ .description("配置 API 凭据")
33
+ .action(async () => {
34
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
35
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
36
+ const apiBaseUrl = (await ask("API Base URL (默认 https://api.openai.com/v1/images): ")).trim()
37
+ || "https://api.openai.com/v1/images";
38
+ const apiKey = await new Promise((resolve) => {
39
+ process.stdout.write("API Key: ");
40
+ const stdin = process.stdin;
41
+ const wasRaw = stdin.isRaw;
42
+ if (stdin.isTTY)
43
+ stdin.setRawMode(true);
44
+ let input = "";
45
+ const onData = (ch) => {
46
+ const c = ch.toString();
47
+ if (c === "\n" || c === "\r") {
48
+ stdin.removeListener("data", onData);
49
+ if (stdin.isTTY)
50
+ stdin.setRawMode(wasRaw ?? false);
51
+ process.stdout.write("\n");
52
+ resolve(input);
53
+ }
54
+ else if (c === "" || c === "\b") {
55
+ input = input.slice(0, -1);
56
+ }
57
+ else if (c === "") {
58
+ process.exit(1);
59
+ }
60
+ else {
61
+ input += c;
62
+ }
63
+ };
64
+ stdin.resume();
65
+ stdin.on("data", onData);
66
+ });
67
+ rl.close();
68
+ if (!apiKey.trim()) {
69
+ console.log("未输入 API Key,取消操作。");
70
+ return;
71
+ }
72
+ saveCredentials(apiBaseUrl, apiKey.trim());
73
+ console.log("");
74
+ console.log("凭据已保存。");
75
+ console.log(` API Base URL: ${apiBaseUrl}`);
76
+ console.log(` API Key: ${maskApiKey(apiKey.trim())}`);
77
+ });
78
+ program
79
+ .command("status")
80
+ .description("查看 companion 状态")
81
+ .action(async () => {
82
+ loadSession();
83
+ const creds = loadCredentials();
84
+ const session = getSessionInfo();
85
+ console.log("┌─────────────────────────────────┐");
86
+ console.log("│ GPT Image Studio Companion │");
87
+ console.log("└─────────────────────────────────┘");
88
+ console.log("");
89
+ if (creds) {
90
+ console.log(`凭据: 已配置`);
91
+ console.log(` Base URL: ${creds.apiBaseUrl}`);
92
+ console.log(` API Key: ${maskApiKey(creds.apiKey)}`);
93
+ console.log(` 保存时间: ${creds.savedAt}`);
94
+ }
95
+ else {
96
+ console.log("凭据: 未配置(运行 gpt-image-studio login 进行配置)");
97
+ }
98
+ console.log(`配对: ${session.paired ? "已配对" : "未配对"}`);
99
+ if (session.expiresAt) {
100
+ console.log(` 过期时间: ${session.expiresAt}`);
101
+ }
102
+ try {
103
+ const res = await fetch("http://127.0.0.1:19750/health", { signal: AbortSignal.timeout(2000) });
104
+ if (res.ok) {
105
+ console.log("服务: 运行中 (127.0.0.1:19750)");
106
+ }
107
+ else {
108
+ console.log("服务: 未运行");
109
+ }
110
+ }
111
+ catch {
112
+ console.log("服务: 未运行");
113
+ }
114
+ });
115
+ program
116
+ .command("logout")
117
+ .description("清除已保存的凭据")
118
+ .action(async () => {
119
+ clearCredentials();
120
+ console.log("凭据已清除。");
121
+ });
122
+ program
123
+ .command("unpair")
124
+ .description("清除网页端配对 session")
125
+ .action(async () => {
126
+ clearSession();
127
+ console.log("配对 session 已清除。");
128
+ });
129
+ program.parse();
@@ -0,0 +1,2 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ export declare function authMiddleware(app: FastifyInstance): Promise<void>;
@@ -0,0 +1,16 @@
1
+ import { validateToken } from "../pairingState.js";
2
+ const PUBLIC_PATHS = ["/health", "/pair/start", "/pair/confirm"];
3
+ export async function authMiddleware(app) {
4
+ app.addHook("onRequest", async (req, reply) => {
5
+ if (PUBLIC_PATHS.includes(req.url))
6
+ return;
7
+ const authHeader = req.headers.authorization;
8
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
9
+ return reply.status(401).send({ error: "未授权:缺少 session token" });
10
+ }
11
+ const token = authHeader.slice(7);
12
+ if (!validateToken(token)) {
13
+ return reply.status(401).send({ error: "未授权:token 无效" });
14
+ }
15
+ });
16
+ }
@@ -0,0 +1,16 @@
1
+ export declare function loadSession(): void;
2
+ export declare function isPaired(): boolean;
3
+ export declare function getSessionInfo(): {
4
+ paired: boolean;
5
+ expiresAt: string | null;
6
+ };
7
+ export declare function startPairing(): {
8
+ pairingCode: string;
9
+ expiresInSeconds: number;
10
+ };
11
+ export declare function confirmPairing(code: string, ttlMs?: number): {
12
+ sessionToken: string;
13
+ expiresAt: string;
14
+ } | null;
15
+ export declare function validateToken(token: string): boolean;
16
+ export declare function clearSession(): void;
@@ -0,0 +1,106 @@
1
+ import { randomUUID, randomInt } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ const CONFIG_DIR = join(homedir(), ".gpt-image-studio");
6
+ const SESSION_FILE = join(CONFIG_DIR, "session.json");
7
+ const PAIRING_CODE_EXPIRY_MS = 5 * 60 * 1000;
8
+ const DEFAULT_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
9
+ let activePairingCode = null;
10
+ let pairingCodeExpiresAt = 0;
11
+ let sessionToken = null;
12
+ let sessionExpiresAt = null;
13
+ export function loadSession() {
14
+ try {
15
+ if (existsSync(SESSION_FILE)) {
16
+ const data = JSON.parse(readFileSync(SESSION_FILE, "utf-8"));
17
+ if (!data.token || !data.expiresAt) {
18
+ sessionToken = null;
19
+ sessionExpiresAt = null;
20
+ return;
21
+ }
22
+ const expiresAt = Date.parse(data.expiresAt);
23
+ if (!Number.isFinite(expiresAt) || Date.now() >= expiresAt) {
24
+ clearSession();
25
+ return;
26
+ }
27
+ sessionToken = data.token;
28
+ sessionExpiresAt = expiresAt;
29
+ }
30
+ }
31
+ catch {
32
+ sessionToken = null;
33
+ sessionExpiresAt = null;
34
+ }
35
+ }
36
+ function saveSession(token, ttlMs) {
37
+ if (!existsSync(CONFIG_DIR)) {
38
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
39
+ }
40
+ const createdAt = new Date();
41
+ const expiresAt = new Date(createdAt.getTime() + ttlMs);
42
+ const data = {
43
+ token,
44
+ createdAt: createdAt.toISOString(),
45
+ expiresAt: expiresAt.toISOString(),
46
+ };
47
+ writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
48
+ chmodSync(SESSION_FILE, 0o600);
49
+ sessionExpiresAt = expiresAt.getTime();
50
+ }
51
+ export function isPaired() {
52
+ return getSessionInfo().paired;
53
+ }
54
+ export function getSessionInfo() {
55
+ if (!sessionToken || !sessionExpiresAt) {
56
+ return { paired: false, expiresAt: null };
57
+ }
58
+ if (Date.now() >= sessionExpiresAt) {
59
+ clearSession();
60
+ return { paired: false, expiresAt: null };
61
+ }
62
+ return { paired: true, expiresAt: new Date(sessionExpiresAt).toISOString() };
63
+ }
64
+ export function startPairing() {
65
+ activePairingCode = String(randomInt(100000, 999999));
66
+ pairingCodeExpiresAt = Date.now() + PAIRING_CODE_EXPIRY_MS;
67
+ console.log("");
68
+ console.log("┌─────────────────────────────────┐");
69
+ console.log("│ 配对码: " + activePairingCode + " │");
70
+ console.log("│ 请在网页端输入此配对码 │");
71
+ console.log("│ 有效期 5 分钟 │");
72
+ console.log("└─────────────────────────────────┘");
73
+ console.log("");
74
+ return {
75
+ pairingCode: activePairingCode,
76
+ expiresInSeconds: Math.floor(PAIRING_CODE_EXPIRY_MS / 1000),
77
+ };
78
+ }
79
+ export function confirmPairing(code, ttlMs = DEFAULT_SESSION_TTL_MS) {
80
+ if (!activePairingCode)
81
+ return null;
82
+ if (Date.now() > pairingCodeExpiresAt) {
83
+ activePairingCode = null;
84
+ return null;
85
+ }
86
+ if (code !== activePairingCode)
87
+ return null;
88
+ activePairingCode = null;
89
+ sessionToken = randomUUID();
90
+ saveSession(sessionToken, ttlMs);
91
+ console.log("配对成功!");
92
+ return { sessionToken, expiresAt: getSessionInfo().expiresAt };
93
+ }
94
+ export function validateToken(token) {
95
+ return getSessionInfo().paired && token === sessionToken;
96
+ }
97
+ export function clearSession() {
98
+ sessionToken = null;
99
+ sessionExpiresAt = null;
100
+ try {
101
+ if (existsSync(SESSION_FILE)) {
102
+ unlinkSync(SESSION_FILE);
103
+ }
104
+ }
105
+ catch { }
106
+ }
@@ -0,0 +1,2 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ export declare function authRoutes(app: FastifyInstance): Promise<void>;
@@ -0,0 +1,20 @@
1
+ import { loadCredentials, maskApiKey } from "../credentials.js";
2
+ export async function authRoutes(app) {
3
+ app.get("/auth/status", async () => {
4
+ const creds = loadCredentials();
5
+ if (!creds) {
6
+ return {
7
+ provider: "openai",
8
+ mode: "api_key",
9
+ ready: false,
10
+ accountLabel: "",
11
+ };
12
+ }
13
+ return {
14
+ provider: "openai",
15
+ mode: "api_key",
16
+ ready: true,
17
+ accountLabel: maskApiKey(creds.apiKey),
18
+ };
19
+ });
20
+ }
@@ -0,0 +1,8 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ import type { CompanionSecurityConfig } from "../securityConfig.js";
3
+ type ImagesRoutesOptions = {
4
+ security: CompanionSecurityConfig;
5
+ };
6
+ export declare function imagesRoutes(app: FastifyInstance, opts: ImagesRoutesOptions): Promise<void>;
7
+ export declare function validateEditMultipart(body: Buffer, security: CompanionSecurityConfig): string | null;
8
+ export {};
@@ -0,0 +1,107 @@
1
+ import { loadCredentials } from "../credentials.js";
2
+ export async function imagesRoutes(app, opts) {
3
+ app.post("/images/generations", async (req, reply) => {
4
+ const creds = loadCredentials();
5
+ if (!creds) {
6
+ return reply.status(503).send({ error: "Companion 未配置凭据,请先运行 login" });
7
+ }
8
+ if (!isJsonRequest(req.headers["content-type"])) {
9
+ return reply.status(415).send({ error: "请求 Content-Type 必须是 application/json" });
10
+ }
11
+ const body = req.body;
12
+ const validationError = validateGenerationBody(body);
13
+ if (validationError) {
14
+ return reply.status(400).send({ error: validationError });
15
+ }
16
+ const apiUrl = `${creds.apiBaseUrl.replace(/\/+$/, "")}/generations`;
17
+ const response = await fetch(apiUrl, {
18
+ method: "POST",
19
+ headers: {
20
+ "Authorization": `Bearer ${creds.apiKey}`,
21
+ "Content-Type": "application/json",
22
+ },
23
+ body: JSON.stringify(body),
24
+ });
25
+ const payload = await response.text();
26
+ return reply.status(response.status).header("content-type", "application/json").send(payload);
27
+ });
28
+ app.addContentTypeParser("multipart/form-data", function (_req, payload, done) {
29
+ const chunks = [];
30
+ payload.on("data", (chunk) => chunks.push(chunk));
31
+ payload.on("end", () => done(null, Buffer.concat(chunks)));
32
+ payload.on("error", done);
33
+ });
34
+ app.post("/images/edits", { bodyLimit: opts.security.maxEditBodyBytes }, async (req, reply) => {
35
+ const creds = loadCredentials();
36
+ if (!creds) {
37
+ return reply.status(503).send({ error: "Companion 未配置凭据,请先运行 login" });
38
+ }
39
+ if (!isMultipartRequest(req.headers["content-type"])) {
40
+ return reply.status(415).send({ error: "请求 Content-Type 必须是 multipart/form-data" });
41
+ }
42
+ const apiUrl = `${creds.apiBaseUrl.replace(/\/+$/, "")}/edits`;
43
+ const contentType = req.headers["content-type"];
44
+ const rawBody = req.body;
45
+ const validationError = validateEditMultipart(rawBody, opts.security);
46
+ if (validationError) {
47
+ return reply.status(400).send({ error: validationError });
48
+ }
49
+ const response = await fetch(apiUrl, {
50
+ method: "POST",
51
+ headers: {
52
+ "Authorization": `Bearer ${creds.apiKey}`,
53
+ "Content-Type": contentType,
54
+ },
55
+ body: new Uint8Array(rawBody),
56
+ });
57
+ const payload = await response.text();
58
+ return reply.status(response.status).header("content-type", "application/json").send(payload);
59
+ });
60
+ }
61
+ function isJsonRequest(contentType) {
62
+ return contentType?.toLowerCase().split(";")[0]?.trim() === "application/json";
63
+ }
64
+ function isMultipartRequest(contentType) {
65
+ return contentType?.toLowerCase().startsWith("multipart/form-data") ?? false;
66
+ }
67
+ function validateGenerationBody(body) {
68
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
69
+ return "请求体必须是 JSON object";
70
+ }
71
+ if (typeof body.model !== "string" || !body.model.trim()) {
72
+ return "缺少 model";
73
+ }
74
+ if (typeof body.prompt !== "string" || !body.prompt.trim()) {
75
+ return "缺少 prompt";
76
+ }
77
+ if ("b64_json" in body || "image" in body || "image[]" in body) {
78
+ return "文生图请求不能包含图片内容";
79
+ }
80
+ return null;
81
+ }
82
+ export function validateEditMultipart(body, security) {
83
+ const text = body.toString("latin1");
84
+ const imagePartNames = [...text.matchAll(/name="image(?:\[\])?"/g)];
85
+ if (imagePartNames.length === 0) {
86
+ return "编辑请求至少需要一张引用图片";
87
+ }
88
+ if (imagePartNames.length > security.maxEditImages) {
89
+ return `编辑请求最多支持 ${security.maxEditImages} 张引用图片`;
90
+ }
91
+ const partHeaders = text.match(/Content-Disposition:[\s\S]*?(?=\r\n\r\n)/g) ?? [];
92
+ for (const header of partHeaders) {
93
+ if (!/name="(?:image(?:\[\])?|mask)"/.test(header))
94
+ continue;
95
+ const mime = /Content-Type:\s*([^\r\n]+)/i.exec(header)?.[1]?.trim().toLowerCase();
96
+ if (!mime) {
97
+ return "图片 part 缺少 Content-Type";
98
+ }
99
+ if (/name="mask"/.test(header) && mime !== "image/png") {
100
+ return "mask 必须是 image/png";
101
+ }
102
+ if (/name="image(?:\[\])?"/.test(header) && !security.allowedEditImageMimeTypes.includes(mime)) {
103
+ return `不支持的图片类型:${mime}`;
104
+ }
105
+ }
106
+ return null;
107
+ }
@@ -0,0 +1,6 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ type PairRoutesOptions = {
3
+ sessionTtlMs: number;
4
+ };
5
+ export declare function pairRoutes(app: FastifyInstance, opts: PairRoutesOptions): Promise<void>;
6
+ export {};
@@ -0,0 +1,15 @@
1
+ import { startPairing, confirmPairing } from "../pairingState.js";
2
+ export async function pairRoutes(app, opts) {
3
+ app.post("/pair/start", async (_req, reply) => {
4
+ const result = startPairing();
5
+ return reply.send(result);
6
+ });
7
+ app.post("/pair/confirm", async (req, reply) => {
8
+ const { pairingCode } = req.body;
9
+ const result = confirmPairing(pairingCode, opts.sessionTtlMs);
10
+ if (!result) {
11
+ return reply.status(401).send({ error: "配对码无效或已过期" });
12
+ }
13
+ return reply.send(result);
14
+ });
15
+ }
@@ -0,0 +1,19 @@
1
+ export type CompanionChannel = "stable" | "dev";
2
+ export type CompanionSecurityConfig = {
3
+ channel: CompanionChannel;
4
+ allowedOrigins: string[];
5
+ sessionTtlMs: number;
6
+ maxJsonBodyBytes: number;
7
+ maxEditBodyBytes: number;
8
+ maxEditImages: number;
9
+ allowedEditImageMimeTypes: string[];
10
+ };
11
+ export declare function resolveChannel(value: string | undefined): CompanionChannel;
12
+ export declare function normalizeOrigin(origin: string): string;
13
+ export declare function parseAllowOrigins(values?: string[]): string[];
14
+ export declare function createSecurityConfig(opts?: {
15
+ channel?: string;
16
+ allowOrigins?: string[];
17
+ sessionTtlDays?: number;
18
+ }): CompanionSecurityConfig;
19
+ export declare function isOriginAllowed(origin: string | undefined, allowedOrigins: string[]): boolean;
@@ -0,0 +1,67 @@
1
+ const STABLE_ORIGINS = ["https://gpt-image.honlnk.com"];
2
+ const DEV_ORIGINS = [
3
+ "https://gpt-image.honlnk.com",
4
+ "http://127.0.0.1:8888",
5
+ "http://localhost:8888",
6
+ ];
7
+ const DEFAULT_SESSION_TTL_DAYS = 30;
8
+ const DEFAULT_JSON_BODY_BYTES = 1024 * 1024;
9
+ const DEFAULT_EDIT_BODY_BYTES = 50 * 1024 * 1024;
10
+ const DEFAULT_MAX_EDIT_IMAGES = 16;
11
+ const DEFAULT_EDIT_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"];
12
+ export function resolveChannel(value) {
13
+ return value === "dev" ? "dev" : "stable";
14
+ }
15
+ export function normalizeOrigin(origin) {
16
+ const trimmed = origin.trim();
17
+ if (!trimmed || trimmed === "*") {
18
+ throw new Error("Origin 必须是完整 origin,不能为空或使用通配符。");
19
+ }
20
+ let url;
21
+ try {
22
+ url = new URL(trimmed);
23
+ }
24
+ catch {
25
+ throw new Error(`Origin 格式无效:${origin}`);
26
+ }
27
+ if (!["http:", "https:"].includes(url.protocol)) {
28
+ throw new Error(`Origin 只支持 http 或 https:${origin}`);
29
+ }
30
+ if (url.pathname !== "/" || url.search || url.hash) {
31
+ throw new Error(`Origin 不能包含路径、查询或 hash:${origin}`);
32
+ }
33
+ if (url.username || url.password) {
34
+ throw new Error(`Origin 不能包含用户名或密码:${origin}`);
35
+ }
36
+ return url.origin;
37
+ }
38
+ export function parseAllowOrigins(values = []) {
39
+ return values.map(normalizeOrigin);
40
+ }
41
+ export function createSecurityConfig(opts = {}) {
42
+ const channel = resolveChannel(opts.channel);
43
+ const baseOrigins = channel === "dev" ? DEV_ORIGINS : STABLE_ORIGINS;
44
+ const extraOrigins = parseAllowOrigins(opts.allowOrigins);
45
+ const sessionTtlDays = Number.isFinite(opts.sessionTtlDays)
46
+ ? opts.sessionTtlDays
47
+ : DEFAULT_SESSION_TTL_DAYS;
48
+ return {
49
+ channel,
50
+ allowedOrigins: Array.from(new Set([...baseOrigins, ...extraOrigins])),
51
+ sessionTtlMs: Math.max(1, sessionTtlDays) * 24 * 60 * 60 * 1000,
52
+ maxJsonBodyBytes: DEFAULT_JSON_BODY_BYTES,
53
+ maxEditBodyBytes: DEFAULT_EDIT_BODY_BYTES,
54
+ maxEditImages: DEFAULT_MAX_EDIT_IMAGES,
55
+ allowedEditImageMimeTypes: DEFAULT_EDIT_IMAGE_MIME_TYPES,
56
+ };
57
+ }
58
+ export function isOriginAllowed(origin, allowedOrigins) {
59
+ if (!origin)
60
+ return true;
61
+ try {
62
+ return allowedOrigins.includes(normalizeOrigin(origin));
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
@@ -0,0 +1,5 @@
1
+ import type { CompanionSecurityConfig } from "./securityConfig.js";
2
+ export declare function startServer(opts: {
3
+ port: number;
4
+ security: CompanionSecurityConfig;
5
+ }): Promise<void>;
package/dist/server.js ADDED
@@ -0,0 +1,50 @@
1
+ import Fastify from "fastify";
2
+ import cors from "@fastify/cors";
3
+ import { loadSession, isPaired } from "./pairingState.js";
4
+ import { pairRoutes } from "./routes/pair.js";
5
+ import { authRoutes } from "./routes/auth.js";
6
+ import { imagesRoutes } from "./routes/images.js";
7
+ import { authMiddleware } from "./middleware/auth.js";
8
+ import { isOriginAllowed } from "./securityConfig.js";
9
+ export async function startServer(opts) {
10
+ loadSession();
11
+ const app = Fastify({
12
+ bodyLimit: opts.security.maxJsonBodyBytes,
13
+ logger: {
14
+ redact: [
15
+ "req.headers.authorization",
16
+ "req.headers.cookie",
17
+ "res.headers.authorization",
18
+ "headers.authorization",
19
+ "apiKey",
20
+ "api_key",
21
+ "b64_json",
22
+ ],
23
+ },
24
+ });
25
+ await app.register(cors, {
26
+ origin: (origin, cb) => {
27
+ cb(null, isOriginAllowed(origin, opts.security.allowedOrigins));
28
+ },
29
+ credentials: true,
30
+ });
31
+ await authMiddleware(app);
32
+ await app.register(pairRoutes, { sessionTtlMs: opts.security.sessionTtlMs });
33
+ await app.register(authRoutes);
34
+ await app.register(imagesRoutes, { security: opts.security });
35
+ app.get("/health", async () => {
36
+ return {
37
+ app: "gpt-image-studio-companion",
38
+ version: "0.1.0",
39
+ paired: isPaired(),
40
+ };
41
+ });
42
+ await app.listen({ host: "127.0.0.1", port: opts.port });
43
+ console.log(`Companion 服务已启动: http://127.0.0.1:${opts.port}`);
44
+ console.log(`安全渠道: ${opts.security.channel}`);
45
+ console.log("允许的 Origin:");
46
+ opts.security.allowedOrigins.forEach((origin) => console.log(` - ${origin}`));
47
+ if (!isPaired()) {
48
+ console.log("等待网页端发起配对...");
49
+ }
50
+ }
@@ -0,0 +1,22 @@
1
+ export type CompanionHealthResponse = {
2
+ app: "gpt-image-studio-companion";
3
+ version: string;
4
+ paired: boolean;
5
+ };
6
+ export type CompanionAuthStatus = {
7
+ provider: string;
8
+ mode: "api_key";
9
+ ready: boolean;
10
+ accountLabel: string;
11
+ };
12
+ export type PairStartResponse = {
13
+ pairingCode: string;
14
+ expiresInSeconds: number;
15
+ };
16
+ export type PairConfirmRequest = {
17
+ pairingCode: string;
18
+ };
19
+ export type PairConfirmResponse = {
20
+ sessionToken: string;
21
+ expiresAt?: string;
22
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@honlnk/image-studio-companion",
3
+ "version": "0.1.0",
4
+ "description": "Local CLI companion for GPT Image Studio image API credential proxying.",
5
+ "type": "module",
6
+ "bin": {
7
+ "gpt-image-studio": "dist/main.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/honlnk/gpt-image-studio.git",
18
+ "directory": "companion"
19
+ },
20
+ "keywords": [
21
+ "gpt-image-studio",
22
+ "openai",
23
+ "images",
24
+ "cli",
25
+ "companion"
26
+ ],
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "scripts": {
31
+ "dev": "tsx src/main.ts serve --channel dev",
32
+ "prepack": "pnpm build",
33
+ "build": "tsc",
34
+ "start": "node dist/main.js serve",
35
+ "typecheck": "tsc --noEmit"
36
+ },
37
+ "dependencies": {
38
+ "@fastify/cors": "^11.0.0",
39
+ "commander": "^13.1.0",
40
+ "fastify": "^5.3.3"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^22.15.0",
44
+ "tsx": "^4.19.0",
45
+ "typescript": "^6.0.3"
46
+ }
47
+ }