@v7-cms/proxy 0.0.0-alpha.9 → 0.0.0-stage
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/README.md +3 -0
- package/package.json +4 -44
- package/dist/cli.mjs +0 -317
package/README.md
ADDED
package/package.json
CHANGED
|
@@ -1,46 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@v7-cms/proxy",
|
|
3
|
-
"version": "0.0.0-
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
"./http": "./src/http.ts"
|
|
8
|
-
},
|
|
9
|
-
"bin": {
|
|
10
|
-
"v7-cms-proxy": "./dist/cli.mjs"
|
|
11
|
-
},
|
|
12
|
-
"scripts": {
|
|
13
|
-
"check": "tsc --noEmit",
|
|
14
|
-
"start": "node dist/cli.mjs",
|
|
15
|
-
"build": "node build.mjs"
|
|
16
|
-
},
|
|
17
|
-
"devDependencies": {
|
|
18
|
-
"esbuild": "0.28.2"
|
|
19
|
-
},
|
|
20
|
-
"files": [
|
|
21
|
-
"dist",
|
|
22
|
-
"README.md"
|
|
23
|
-
],
|
|
24
|
-
"license": "MIT",
|
|
25
|
-
"repository": {
|
|
26
|
-
"type": "git",
|
|
27
|
-
"url": "git+https://github.com/phishinqi/v7-cms.git",
|
|
28
|
-
"directory": "packages/proxy"
|
|
29
|
-
},
|
|
30
|
-
"homepage": "https://github.com/phishinqi/v7-cms#readme",
|
|
31
|
-
"bugs": {
|
|
32
|
-
"url": "https://github.com/phishinqi/v7-cms/issues"
|
|
33
|
-
},
|
|
34
|
-
"publishConfig": {
|
|
35
|
-
"access": "public",
|
|
36
|
-
"registry": "https://registry.npmjs.org"
|
|
37
|
-
},
|
|
38
|
-
"description": "Local write proxy for v7-cms, for browsers without the File System Access API.",
|
|
39
|
-
"keywords": [
|
|
40
|
-
"cms",
|
|
41
|
-
"git",
|
|
42
|
-
"proxy",
|
|
43
|
-
"local",
|
|
44
|
-
"v7-cms"
|
|
45
|
-
]
|
|
46
|
-
}
|
|
3
|
+
"version": "0.0.0-stage",
|
|
4
|
+
"stub": true,
|
|
5
|
+
"description": "Temporary package placeholder for staged publishing"
|
|
6
|
+
}
|
package/dist/cli.mjs
DELETED
|
@@ -1,317 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
// src/cli.ts
|
|
4
|
-
import { resolve as resolve2 } from "node:path";
|
|
5
|
-
|
|
6
|
-
// src/http.ts
|
|
7
|
-
import { createServer } from "node:http";
|
|
8
|
-
import { randomBytes } from "node:crypto";
|
|
9
|
-
|
|
10
|
-
// src/server.ts
|
|
11
|
-
import { mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
12
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
13
|
-
var json = (body, status = 200) => ({ status, body });
|
|
14
|
-
var LocalProxy = class {
|
|
15
|
-
root;
|
|
16
|
-
token;
|
|
17
|
-
mediaFolder;
|
|
18
|
-
constructor(options) {
|
|
19
|
-
this.root = resolve(options.root);
|
|
20
|
-
this.token = options.token ?? null;
|
|
21
|
-
this.mediaFolder = options.mediaFolder ?? "public/images/uploads";
|
|
22
|
-
}
|
|
23
|
-
async handle(request) {
|
|
24
|
-
if (request.method === "OPTIONS") return { status: 204, body: null };
|
|
25
|
-
if (request.path === "/api/health") {
|
|
26
|
-
return json({ repo: basename(this.root), version: 1 });
|
|
27
|
-
}
|
|
28
|
-
if (this.token && request.token !== this.token) {
|
|
29
|
-
return json({ error: "Missing or wrong proxy token." }, 401);
|
|
30
|
-
}
|
|
31
|
-
try {
|
|
32
|
-
switch (request.path) {
|
|
33
|
-
case "/api/v1":
|
|
34
|
-
return await this.content(request);
|
|
35
|
-
case "/api/media":
|
|
36
|
-
return await this.media(request);
|
|
37
|
-
default:
|
|
38
|
-
return json({ error: "Not found" }, 404);
|
|
39
|
-
}
|
|
40
|
-
} catch (error) {
|
|
41
|
-
const message = error.message;
|
|
42
|
-
const status = /outside the repository|escapes/.test(message) ? 403 : 500;
|
|
43
|
-
return json({ error: message }, status);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* One call carries one storage operation. The shape mirrors the GitHub adapter's capabilities
|
|
48
|
-
* so the editor cannot tell the two apart.
|
|
49
|
-
*/
|
|
50
|
-
async content(request) {
|
|
51
|
-
const body = request.body ?? {};
|
|
52
|
-
switch (body["action"]) {
|
|
53
|
-
case "info":
|
|
54
|
-
return json({ repo: basename(this.root), root: this.root, version: 1 });
|
|
55
|
-
case "listDir":
|
|
56
|
-
return json({ entries: await this.listDir(String(body["path"] ?? "")) });
|
|
57
|
-
case "readFile":
|
|
58
|
-
return json({ file: await this.readFile(String(body["path"])) });
|
|
59
|
-
case "exists":
|
|
60
|
-
return json({ exists: await this.exists(String(body["path"])) });
|
|
61
|
-
case "writeFile":
|
|
62
|
-
return json({ result: await this.writeFile(body) });
|
|
63
|
-
case "deleteFile":
|
|
64
|
-
await this.deleteFile(String(body["path"]));
|
|
65
|
-
return json({ ok: true });
|
|
66
|
-
default:
|
|
67
|
-
return json({ error: `Unknown action "${String(body["action"])}"` }, 400);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
async listDir(path) {
|
|
71
|
-
const full = await this.resolve(path);
|
|
72
|
-
const entries = await readdir(full, { withFileTypes: true }).catch(() => []);
|
|
73
|
-
return entries.filter((entry) => !entry.name.startsWith(".")).map((entry) => ({
|
|
74
|
-
name: entry.name,
|
|
75
|
-
type: entry.isDirectory() ? "dir" : "file"
|
|
76
|
-
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
77
|
-
}
|
|
78
|
-
async readFile(path) {
|
|
79
|
-
const full = await this.resolve(path);
|
|
80
|
-
const info = await stat(full).catch(() => null);
|
|
81
|
-
if (!info?.isFile()) throw new Error(`Not found: ${path}`);
|
|
82
|
-
const buffer = await readFile(full);
|
|
83
|
-
const isText = /\.(md|mdx|json|ya?ml|txt|css|js|ts|astro)$/i.test(path);
|
|
84
|
-
return {
|
|
85
|
-
text: isText ? buffer.toString("utf8") : void 0,
|
|
86
|
-
base64: isText ? void 0 : buffer.toString("base64"),
|
|
87
|
-
size: info.size
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
async exists(path) {
|
|
91
|
-
const full = await this.resolve(path);
|
|
92
|
-
return stat(full).then(
|
|
93
|
-
(info) => info.isFile(),
|
|
94
|
-
() => false
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
async writeFile(body) {
|
|
98
|
-
const path = String(body["path"]);
|
|
99
|
-
const full = await this.resolve(path, { forWrite: true });
|
|
100
|
-
await mkdir(dirname(full), { recursive: true });
|
|
101
|
-
const contents = typeof body["base64"] === "string" ? Buffer.from(body["base64"], "base64") : String(body["text"] ?? "");
|
|
102
|
-
await writeFile(full, contents);
|
|
103
|
-
const info = await stat(full);
|
|
104
|
-
return { size: info.size, mtime: info.mtimeMs };
|
|
105
|
-
}
|
|
106
|
-
async deleteFile(path) {
|
|
107
|
-
const full = await this.resolve(path, { forWrite: true });
|
|
108
|
-
const info = await stat(full).catch(() => null);
|
|
109
|
-
if (!info) throw new Error(`Not found: ${path}`);
|
|
110
|
-
await rm(full);
|
|
111
|
-
}
|
|
112
|
-
async media(request) {
|
|
113
|
-
if (request.method === "GET") {
|
|
114
|
-
const folder = await this.resolve(this.mediaFolder);
|
|
115
|
-
const names = await readdir(folder).catch(() => []);
|
|
116
|
-
return json({
|
|
117
|
-
assets: names.map((name2) => ({
|
|
118
|
-
path: `${this.mediaFolder}/${name2}`,
|
|
119
|
-
url: `/${this.mediaFolder}/${name2}`,
|
|
120
|
-
name: name2
|
|
121
|
-
})),
|
|
122
|
-
cursor: null
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
const body = request.body ?? {};
|
|
126
|
-
const name = String(body["name"] ?? "");
|
|
127
|
-
if (!name || /[\\/]/.test(name)) return json({ error: "Invalid file name" }, 400);
|
|
128
|
-
const path = `${this.mediaFolder}/${name}`;
|
|
129
|
-
await this.writeFile({ path, base64: body["base64"] });
|
|
130
|
-
return json({ path, url: `/${path}`, name }, 201);
|
|
131
|
-
}
|
|
132
|
-
/**
|
|
133
|
-
* Resolve a repository-relative path to an absolute one, refusing anything that leaves the root.
|
|
134
|
-
* `forWrite` also allows the file itself not to exist yet, while still checking its parent.
|
|
135
|
-
*/
|
|
136
|
-
async resolve(path, options = {}) {
|
|
137
|
-
if (isAbsolute(path)) throw new Error(`${path} is outside the repository.`);
|
|
138
|
-
const full = resolve(this.root, path);
|
|
139
|
-
const rel = relative(this.root, full);
|
|
140
|
-
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
141
|
-
throw new Error(`${path} is outside the repository.`);
|
|
142
|
-
}
|
|
143
|
-
if (!options.forWrite) {
|
|
144
|
-
const real = await realpath(full).catch(() => null);
|
|
145
|
-
if (real) {
|
|
146
|
-
const realRel = relative(await realpath(this.root), real);
|
|
147
|
-
if (realRel.startsWith("..") || isAbsolute(realRel)) {
|
|
148
|
-
throw new Error(`${path} escapes the repository.`);
|
|
149
|
-
}
|
|
150
|
-
return real;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
return full;
|
|
154
|
-
}
|
|
155
|
-
};
|
|
156
|
-
var basename = (path) => path.split(sep).filter(Boolean).pop() ?? path;
|
|
157
|
-
var TOKEN_HEADER = "x-v7-cms-token";
|
|
158
|
-
|
|
159
|
-
// src/http.ts
|
|
160
|
-
async function startProxy(options) {
|
|
161
|
-
const token = options.token ?? randomBytes(24).toString("hex");
|
|
162
|
-
const host = options.host ?? "127.0.0.1";
|
|
163
|
-
const proxy = new LocalProxy({
|
|
164
|
-
root: options.root,
|
|
165
|
-
token,
|
|
166
|
-
...options.mediaFolder ? { mediaFolder: options.mediaFolder } : {}
|
|
167
|
-
});
|
|
168
|
-
const allowed = new Set(options.allowedOrigins ?? []);
|
|
169
|
-
const server = createServer((request, response) => {
|
|
170
|
-
void handle(request, response, proxy, allowed);
|
|
171
|
-
});
|
|
172
|
-
await new Promise((resolve3, reject) => {
|
|
173
|
-
server.once("error", reject);
|
|
174
|
-
server.listen(options.port ?? 0, host, resolve3);
|
|
175
|
-
});
|
|
176
|
-
const address = server.address();
|
|
177
|
-
const port = typeof address === "object" && address ? address.port : options.port ?? 0;
|
|
178
|
-
return {
|
|
179
|
-
server,
|
|
180
|
-
port,
|
|
181
|
-
token,
|
|
182
|
-
url: `http://${host}:${port}`,
|
|
183
|
-
close: () => new Promise(
|
|
184
|
-
(resolve3, reject) => server.close((error) => error ? reject(error) : resolve3())
|
|
185
|
-
)
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
async function handle(request, response, proxy, allowed) {
|
|
189
|
-
const origin = request.headers.origin;
|
|
190
|
-
if (origin) {
|
|
191
|
-
const permitted = allowed.has(origin) || /^http:\/\/(localhost|127\.0\.0\.1|\[::1\]):\d+$/.test(origin);
|
|
192
|
-
if (!permitted) {
|
|
193
|
-
response.writeHead(403, { "Content-Type": "application/json" });
|
|
194
|
-
response.end(JSON.stringify({ error: "Origin not allowed" }));
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
response.setHeader("Access-Control-Allow-Origin", origin);
|
|
198
|
-
response.setHeader("Vary", "Origin");
|
|
199
|
-
response.setHeader(
|
|
200
|
-
"Access-Control-Allow-Headers",
|
|
201
|
-
`Content-Type, ${TOKEN_HEADER}, Authorization`
|
|
202
|
-
);
|
|
203
|
-
response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
204
|
-
}
|
|
205
|
-
const url = new URL(request.url ?? "/", "http://localhost");
|
|
206
|
-
const body = await readBody(request);
|
|
207
|
-
const header = request.headers[TOKEN_HEADER];
|
|
208
|
-
const bearer = request.headers.authorization?.replace(/^Bearer /, "");
|
|
209
|
-
const result = await proxy.handle({
|
|
210
|
-
method: request.method ?? "GET",
|
|
211
|
-
path: url.pathname,
|
|
212
|
-
query: url.search.replace(/^\?/, ""),
|
|
213
|
-
body,
|
|
214
|
-
...typeof header === "string" ? { token: header } : typeof bearer === "string" ? { token: bearer } : {}
|
|
215
|
-
});
|
|
216
|
-
response.writeHead(result.status, {
|
|
217
|
-
"Content-Type": "application/json",
|
|
218
|
-
"Cache-Control": "no-store"
|
|
219
|
-
});
|
|
220
|
-
response.end(JSON.stringify(result.body ?? null));
|
|
221
|
-
}
|
|
222
|
-
var MAX_BODY = 32 * 1024 * 1024;
|
|
223
|
-
function readBody(request) {
|
|
224
|
-
if (request.method === "GET" || request.method === "OPTIONS") return Promise.resolve(void 0);
|
|
225
|
-
return new Promise((resolve3) => {
|
|
226
|
-
const chunks = [];
|
|
227
|
-
let size = 0;
|
|
228
|
-
request.on("data", (chunk) => {
|
|
229
|
-
size += chunk.length;
|
|
230
|
-
if (size > MAX_BODY) {
|
|
231
|
-
request.destroy();
|
|
232
|
-
resolve3(void 0);
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
chunks.push(chunk);
|
|
236
|
-
});
|
|
237
|
-
request.on("end", () => {
|
|
238
|
-
const text = Buffer.concat(chunks).toString("utf8");
|
|
239
|
-
if (!text) return resolve3(void 0);
|
|
240
|
-
try {
|
|
241
|
-
resolve3(JSON.parse(text));
|
|
242
|
-
} catch {
|
|
243
|
-
resolve3(void 0);
|
|
244
|
-
}
|
|
245
|
-
});
|
|
246
|
-
request.on("error", () => resolve3(void 0));
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// src/cli.ts
|
|
251
|
-
async function main(argv = process.argv.slice(2)) {
|
|
252
|
-
const args = /* @__PURE__ */ new Map();
|
|
253
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
254
|
-
const token2 = argv[index];
|
|
255
|
-
if (!token2.startsWith("--")) continue;
|
|
256
|
-
const next = argv[index + 1];
|
|
257
|
-
if (next && !next.startsWith("--")) {
|
|
258
|
-
args.set(token2, next);
|
|
259
|
-
index += 1;
|
|
260
|
-
} else {
|
|
261
|
-
args.set(token2, true);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
if (args.has("--help") || args.has("-h")) {
|
|
265
|
-
process.stdout.write(
|
|
266
|
-
[
|
|
267
|
-
"v7-cms local proxy",
|
|
268
|
-
"",
|
|
269
|
-
"Usage: v7-cms-proxy [--root <dir>] [--port <n>] [--host <addr>] [--token <token>]",
|
|
270
|
-
"",
|
|
271
|
-
" --root Repository root the editor may read and write. Default: the current directory.",
|
|
272
|
-
" --port Port to listen on. Default: 5177.",
|
|
273
|
-
" --host Interface to bind. Default: 127.0.0.1. Do not expose this to a network.",
|
|
274
|
-
" --token Fixed token. Default: a new one each run.",
|
|
275
|
-
""
|
|
276
|
-
].join("\n")
|
|
277
|
-
);
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
const root = resolve2(
|
|
281
|
-
typeof args.get("--root") === "string" ? args.get("--root") : "."
|
|
282
|
-
);
|
|
283
|
-
const port = Number(typeof args.get("--port") === "string" ? args.get("--port") : 5177);
|
|
284
|
-
const host = typeof args.get("--host") === "string" ? args.get("--host") : "127.0.0.1";
|
|
285
|
-
const token = typeof args.get("--token") === "string" ? args.get("--token") : void 0;
|
|
286
|
-
if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") {
|
|
287
|
-
process.stderr.write(
|
|
288
|
-
`Refusing to bind ${host}: this proxy can write files and must stay on loopback.
|
|
289
|
-
`
|
|
290
|
-
);
|
|
291
|
-
process.exitCode = 1;
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
const proxy = await startProxy({ root, port, host, ...token ? { token } : {} });
|
|
295
|
-
process.stdout.write(
|
|
296
|
-
[
|
|
297
|
-
"",
|
|
298
|
-
`v7-cms local proxy listening on ${proxy.url}`,
|
|
299
|
-
` root: ${root}`,
|
|
300
|
-
` token: ${proxy.token}`,
|
|
301
|
-
"",
|
|
302
|
-
"Paste the URL and token into the editor. Press Ctrl+C to stop.",
|
|
303
|
-
""
|
|
304
|
-
].join("\n")
|
|
305
|
-
);
|
|
306
|
-
const stop = () => {
|
|
307
|
-
void proxy.close().then(() => process.exit(0));
|
|
308
|
-
};
|
|
309
|
-
process.on("SIGINT", stop);
|
|
310
|
-
process.on("SIGTERM", stop);
|
|
311
|
-
}
|
|
312
|
-
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/").split("/").pop())) {
|
|
313
|
-
await main();
|
|
314
|
-
}
|
|
315
|
-
export {
|
|
316
|
-
main
|
|
317
|
-
};
|