vite-plugin-webide 0.0.1
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 +66 -0
- package/client.d.ts +12 -0
- package/dist/index.cjs +368 -0
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +336 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Vite Plugin WebIDE
|
|
2
|
+
|
|
3
|
+
一个为 Vite 项目提供即时 Web IDE 环境的插件。只需简单配置,即可在浏览器中浏览、编辑项目文件,并安全地分享给局域网内的协作者。
|
|
4
|
+
|
|
5
|
+
## ✨ 特性
|
|
6
|
+
|
|
7
|
+
- **零配置集成**:开箱即用,自动集成到 Vite 开发服务器。
|
|
8
|
+
- **专业级编辑器**:内置 Monaco Editor(VS Code 同款核心),提供代码高亮、智能提示和缩略图。
|
|
9
|
+
- **安全分享机制**:
|
|
10
|
+
- **权限控制**:支持生成“只读”或“读写”权限的分享链接。
|
|
11
|
+
- **密码保护**:可为分享链接设置访问密码,确保代码安全。
|
|
12
|
+
- **有效期管理**:支持设置 1小时、24小时或永久有效的分享链接。
|
|
13
|
+
- **无状态设计**:Token 基于签名验证,重启服务即失效(阅后即焚),无持久化隐患。
|
|
14
|
+
- **便捷协作**:
|
|
15
|
+
- **自动局域网 IP**:生成的分享链接自动使用局域网 IP,方便同一网络下的设备访问。
|
|
16
|
+
- **二维码分享**:内置二维码生成器,手机扫码即可快速预览或审查代码。
|
|
17
|
+
- **剪贴板集成**:一键复制分享链接,配合优雅的 Toast 提示。
|
|
18
|
+
- **沉浸式体验**:现代化的深色主题 UI,专注代码编写。
|
|
19
|
+
|
|
20
|
+
## 📦 安装
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install vite-plugin-webide --save-dev
|
|
24
|
+
# 或者
|
|
25
|
+
pnpm add -D vite-plugin-webide
|
|
26
|
+
# 或者
|
|
27
|
+
yarn add -D vite-plugin-webide
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## 🚀 使用
|
|
31
|
+
|
|
32
|
+
在你的 `vite.config.ts` 中引入并注册插件:
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import { defineConfig } from 'vite'
|
|
36
|
+
import webIde from 'vite-plugin-webide'
|
|
37
|
+
|
|
38
|
+
export default defineConfig({
|
|
39
|
+
plugins: [
|
|
40
|
+
webIde({
|
|
41
|
+
// 插件配置选项(可选)
|
|
42
|
+
base: '/__webide__' // 访问路径,默认为 /__webide__
|
|
43
|
+
})
|
|
44
|
+
]
|
|
45
|
+
})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
启动 Vite 开发服务器后,访问 `http://localhost:5173/__webide__` (端口取决于你的配置) 即可进入 IDE。
|
|
49
|
+
|
|
50
|
+
## 🛠 配置选项
|
|
51
|
+
|
|
52
|
+
| 选项 | 类型 | 默认值 | 描述 |
|
|
53
|
+
| --- | --- | --- | --- |
|
|
54
|
+
| `base` | `string` | `/__webide__` | IDE 页面的基础路径。建议保持默认,除非与项目路由冲突。 |
|
|
55
|
+
|
|
56
|
+
## 🔒 安全说明
|
|
57
|
+
|
|
58
|
+
本插件专为开发环境设计,旨在简化局域网内的代码分享与协作。
|
|
59
|
+
|
|
60
|
+
1. **开发环境专用**:插件仅在 `serve` 模式下应用,不会影响生产构建。
|
|
61
|
+
2. **局域网访问**:分享链接包含你的局域网 IP,请确保在受信任的网络环境中使用。
|
|
62
|
+
3. **Token 签名**:分享链接中的 Token 包含权限和有效期信息,并经过服务端签名。修改 URL 参数无法提升权限。
|
|
63
|
+
|
|
64
|
+
## 📝 License
|
|
65
|
+
|
|
66
|
+
MIT
|
package/client.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
declare module '*.vue' {
|
|
2
|
+
import type { DefineComponent } from 'vue'
|
|
3
|
+
const component: DefineComponent<{}, {}, any>
|
|
4
|
+
export default component
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
declare module '*?worker' {
|
|
8
|
+
const workerConstructor: {
|
|
9
|
+
new (): Worker
|
|
10
|
+
}
|
|
11
|
+
export default workerConstructor
|
|
12
|
+
}
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
default: () => webIdePlugin
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(index_exports);
|
|
36
|
+
var import_node_fs = __toESM(require("fs"), 1);
|
|
37
|
+
var import_node_os = __toESM(require("os"), 1);
|
|
38
|
+
var import_node_path = require("path");
|
|
39
|
+
var import_node_url = require("url");
|
|
40
|
+
var import_sirv = __toESM(require("sirv"), 1);
|
|
41
|
+
var import_picocolors = __toESM(require("picocolors"), 1);
|
|
42
|
+
|
|
43
|
+
// src/security.ts
|
|
44
|
+
var import_node_crypto = require("crypto");
|
|
45
|
+
var SecurityManager = class {
|
|
46
|
+
// Use a fixed secret or generate one. If random, tokens invalidate on restart (desired behavior).
|
|
47
|
+
secret = (0, import_node_crypto.randomBytes)(32).toString("hex");
|
|
48
|
+
constructor() {
|
|
49
|
+
}
|
|
50
|
+
createShare(options) {
|
|
51
|
+
let expiresAt = void 0;
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
if (options.validity === "1h") expiresAt = now + 3600 * 1e3;
|
|
54
|
+
if (options.validity === "24h") expiresAt = now + 24 * 3600 * 1e3;
|
|
55
|
+
const payload = {
|
|
56
|
+
acc: options.access === "readonly" ? "r" : "w",
|
|
57
|
+
iat: now
|
|
58
|
+
};
|
|
59
|
+
if (expiresAt) payload.exp = expiresAt;
|
|
60
|
+
if (options.password) {
|
|
61
|
+
payload.pwd = this.hashPassword(options.password);
|
|
62
|
+
}
|
|
63
|
+
const token = this.signToken(payload);
|
|
64
|
+
return { token };
|
|
65
|
+
}
|
|
66
|
+
getSession(token) {
|
|
67
|
+
const payload = this.verifyToken(token);
|
|
68
|
+
if (!payload) return void 0;
|
|
69
|
+
return {
|
|
70
|
+
token,
|
|
71
|
+
access: payload.acc === "r" ? "readonly" : "readwrite",
|
|
72
|
+
expiresAt: payload.exp || null,
|
|
73
|
+
passwordHash: payload.pwd,
|
|
74
|
+
createdAt: payload.iat
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// Check if request is allowed
|
|
78
|
+
validate(req, res) {
|
|
79
|
+
const remoteAddr = req.socket.remoteAddress;
|
|
80
|
+
const isLocal = remoteAddr === "127.0.0.1" || remoteAddr === "::1" || remoteAddr === "::ffff:127.0.0.1";
|
|
81
|
+
if (isLocal) return true;
|
|
82
|
+
let token = "";
|
|
83
|
+
const authHeader = req.headers["authorization"];
|
|
84
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
85
|
+
token = authHeader.substring(7);
|
|
86
|
+
}
|
|
87
|
+
if (!token) {
|
|
88
|
+
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
|
89
|
+
token = url.searchParams.get("token") || "";
|
|
90
|
+
}
|
|
91
|
+
if (!token) {
|
|
92
|
+
res.statusCode = 403;
|
|
93
|
+
res.end(JSON.stringify({ error: "Access denied: No token provided" }));
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
const session = this.getSession(token);
|
|
97
|
+
if (!session) {
|
|
98
|
+
res.statusCode = 403;
|
|
99
|
+
res.end(JSON.stringify({ error: "Access denied: Invalid or expired token" }));
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
if (session.expiresAt && Date.now() > session.expiresAt) {
|
|
103
|
+
res.statusCode = 403;
|
|
104
|
+
res.end(JSON.stringify({ error: "Access denied: Token expired" }));
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
if (session.passwordHash) {
|
|
108
|
+
const url = req.url || "";
|
|
109
|
+
if (!url.includes("/auth/verify")) {
|
|
110
|
+
const providedPass = req.headers["x-webide-password"];
|
|
111
|
+
if (!providedPass || this.hashPassword(providedPass) !== session.passwordHash) {
|
|
112
|
+
res.statusCode = 401;
|
|
113
|
+
res.end(JSON.stringify({ error: "Password required", type: "password_required" }));
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (req.method === "POST" || req.method === "PUT" || req.method === "DELETE") {
|
|
119
|
+
if (req.url?.includes("/auth/verify")) return true;
|
|
120
|
+
if (session.access === "readonly") {
|
|
121
|
+
res.statusCode = 403;
|
|
122
|
+
res.end(JSON.stringify({ error: "Read-only access" }));
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
verifyPassword(token, password) {
|
|
129
|
+
const session = this.getSession(token);
|
|
130
|
+
if (!session || !session.passwordHash) return true;
|
|
131
|
+
return this.hashPassword(password) === session.passwordHash;
|
|
132
|
+
}
|
|
133
|
+
hashPassword(password) {
|
|
134
|
+
return (0, import_node_crypto.createHmac)("sha256", this.secret).update(password).digest("hex");
|
|
135
|
+
}
|
|
136
|
+
signToken(payload) {
|
|
137
|
+
const json = JSON.stringify(payload);
|
|
138
|
+
const b64 = Buffer.from(json).toString("base64url");
|
|
139
|
+
const signature = (0, import_node_crypto.createHmac)("sha256", this.secret).update(b64).digest("base64url");
|
|
140
|
+
return `${b64}.${signature}`;
|
|
141
|
+
}
|
|
142
|
+
verifyToken(token) {
|
|
143
|
+
try {
|
|
144
|
+
const [b64, signature] = token.split(".");
|
|
145
|
+
if (!b64 || !signature) return null;
|
|
146
|
+
const expectedSig = (0, import_node_crypto.createHmac)("sha256", this.secret).update(b64).digest("base64url");
|
|
147
|
+
if (signature !== expectedSig) return null;
|
|
148
|
+
const json = Buffer.from(b64, "base64url").toString();
|
|
149
|
+
return JSON.parse(json);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// src/index.ts
|
|
157
|
+
var import_meta = {};
|
|
158
|
+
var DIR_DIST = typeof __dirname !== "undefined" ? __dirname : (0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(import_meta.url));
|
|
159
|
+
var DIR_CLIENT = (0, import_node_path.resolve)(DIR_DIST, "client");
|
|
160
|
+
function webIdePlugin(options = {}) {
|
|
161
|
+
const base = options.base || "/__webide__";
|
|
162
|
+
const apiBase = `${base}/api`;
|
|
163
|
+
const security = new SecurityManager();
|
|
164
|
+
return {
|
|
165
|
+
name: "vite-plugin-webide",
|
|
166
|
+
apply: "serve",
|
|
167
|
+
configureServer(server) {
|
|
168
|
+
server.middlewares.use(base, (req, res, next) => {
|
|
169
|
+
if (!req.url) return next();
|
|
170
|
+
if (req.url.startsWith("/api/share/create") && req.method === "POST") {
|
|
171
|
+
const remoteAddr = req.socket.remoteAddress;
|
|
172
|
+
const isLocal = remoteAddr === "127.0.0.1" || remoteAddr === "::1" || remoteAddr === "::ffff:127.0.0.1";
|
|
173
|
+
if (!isLocal) {
|
|
174
|
+
res.statusCode = 403;
|
|
175
|
+
res.end(JSON.stringify({ error: "Only localhost can create share links" }));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
let body = "";
|
|
179
|
+
req.on("data", (chunk) => body += chunk);
|
|
180
|
+
req.on("end", () => {
|
|
181
|
+
try {
|
|
182
|
+
const opts = JSON.parse(body);
|
|
183
|
+
const { token } = security.createShare(opts);
|
|
184
|
+
const interfaces = import_node_os.default.networkInterfaces();
|
|
185
|
+
let localIp = "localhost";
|
|
186
|
+
for (const key in interfaces) {
|
|
187
|
+
for (const iface of interfaces[key] || []) {
|
|
188
|
+
if (iface.family === "IPv4" && !iface.internal) {
|
|
189
|
+
localIp = iface.address;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (localIp !== "localhost") break;
|
|
194
|
+
}
|
|
195
|
+
const protocol = server.config.server.https ? "https" : "http";
|
|
196
|
+
const port = server.config.server.port;
|
|
197
|
+
const url = `${protocol}://${localIp}:${port}${base}?token=${token}`;
|
|
198
|
+
res.setHeader("Content-Type", "application/json");
|
|
199
|
+
res.end(JSON.stringify({ token, url }));
|
|
200
|
+
} catch (e) {
|
|
201
|
+
res.statusCode = 500;
|
|
202
|
+
res.end(JSON.stringify({ error: String(e) }));
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (req.url.startsWith("/api/auth/verify") && req.method === "POST") {
|
|
208
|
+
let body = "";
|
|
209
|
+
req.on("data", (chunk) => body += chunk);
|
|
210
|
+
req.on("end", () => {
|
|
211
|
+
try {
|
|
212
|
+
const { token, password } = JSON.parse(body);
|
|
213
|
+
const isValid = security.verifyPassword(token, password);
|
|
214
|
+
if (isValid) {
|
|
215
|
+
res.setHeader("Content-Type", "application/json");
|
|
216
|
+
res.end(JSON.stringify({ success: true }));
|
|
217
|
+
} else {
|
|
218
|
+
res.statusCode = 401;
|
|
219
|
+
res.end(JSON.stringify({ error: "Invalid password" }));
|
|
220
|
+
}
|
|
221
|
+
} catch (e) {
|
|
222
|
+
res.statusCode = 500;
|
|
223
|
+
res.end(JSON.stringify({ error: String(e) }));
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (req.url.startsWith("/api")) {
|
|
229
|
+
if (!security.validate(req, res)) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
next();
|
|
234
|
+
});
|
|
235
|
+
server.middlewares.use(apiBase, (req, res, next) => {
|
|
236
|
+
if (!req.url) return next();
|
|
237
|
+
if (req.url.startsWith("/api/auth/me")) {
|
|
238
|
+
let token = "";
|
|
239
|
+
const authHeader = req.headers["authorization"];
|
|
240
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
241
|
+
token = authHeader.substring(7);
|
|
242
|
+
} else {
|
|
243
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
244
|
+
token = url.searchParams.get("token") || "";
|
|
245
|
+
}
|
|
246
|
+
const session = security.getSession(token);
|
|
247
|
+
const access = session ? session.access : "readwrite";
|
|
248
|
+
res.setHeader("Content-Type", "application/json");
|
|
249
|
+
res.end(JSON.stringify({ access }));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (req.url.startsWith("/list")) {
|
|
253
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
254
|
+
const dirPath = url.searchParams.get("path") || "/";
|
|
255
|
+
try {
|
|
256
|
+
const absolutePath = (0, import_node_path.resolve)(process.cwd(), dirPath.replace(/^\//, ""));
|
|
257
|
+
if (!import_node_fs.default.existsSync(absolutePath)) {
|
|
258
|
+
res.statusCode = 404;
|
|
259
|
+
res.end(JSON.stringify({ error: "Path not found" }));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const items = import_node_fs.default.readdirSync(absolutePath, { withFileTypes: true }).map((dirent) => ({
|
|
263
|
+
name: dirent.name,
|
|
264
|
+
isFile: dirent.isFile(),
|
|
265
|
+
path: (0, import_node_path.join)(dirPath, dirent.name).replace(/\\/g, "/")
|
|
266
|
+
}));
|
|
267
|
+
res.setHeader("Content-Type", "application/json");
|
|
268
|
+
res.end(JSON.stringify(items));
|
|
269
|
+
} catch (e) {
|
|
270
|
+
console.error(import_picocolors.default.red(`[WebIDE] Error listing directory ${dirPath}:`), e);
|
|
271
|
+
res.statusCode = 500;
|
|
272
|
+
res.end(JSON.stringify({ error: String(e) }));
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (req.url.startsWith("/read")) {
|
|
277
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
278
|
+
const filePath = url.searchParams.get("path");
|
|
279
|
+
if (!filePath) {
|
|
280
|
+
res.statusCode = 400;
|
|
281
|
+
res.end(JSON.stringify({ error: "Missing path parameter" }));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const safePath = filePath.replace(/^\//, "");
|
|
286
|
+
if (safePath.includes("..")) {
|
|
287
|
+
res.statusCode = 403;
|
|
288
|
+
res.end(JSON.stringify({ error: "Access denied" }));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const absolutePath = (0, import_node_path.resolve)(process.cwd(), safePath);
|
|
292
|
+
if (!import_node_fs.default.existsSync(absolutePath) || !import_node_fs.default.statSync(absolutePath).isFile()) {
|
|
293
|
+
res.statusCode = 404;
|
|
294
|
+
res.end(JSON.stringify({ error: "File not found" }));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
298
|
+
const isImage = ["png", "jpg", "jpeg", "gif", "svg", "webp", "ico"].includes(ext || "");
|
|
299
|
+
if (isImage) {
|
|
300
|
+
const content = import_node_fs.default.readFileSync(absolutePath, "base64");
|
|
301
|
+
const mimeType = ext === "svg" ? "svg+xml" : ext;
|
|
302
|
+
res.setHeader("Content-Type", "application/json");
|
|
303
|
+
res.end(JSON.stringify({ content: `data:image/${mimeType};base64,${content}` }));
|
|
304
|
+
} else {
|
|
305
|
+
const content = import_node_fs.default.readFileSync(absolutePath, "utf-8");
|
|
306
|
+
res.setHeader("Content-Type", "application/json");
|
|
307
|
+
res.end(JSON.stringify({ content }));
|
|
308
|
+
}
|
|
309
|
+
} catch (e) {
|
|
310
|
+
console.error(import_picocolors.default.red(`[WebIDE] Error reading file ${filePath}:`), e);
|
|
311
|
+
res.statusCode = 500;
|
|
312
|
+
res.end(JSON.stringify({ error: String(e) }));
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (req.url.startsWith("/save") && req.method === "POST") {
|
|
317
|
+
let body = "";
|
|
318
|
+
req.on("data", (chunk) => {
|
|
319
|
+
body += chunk.toString();
|
|
320
|
+
});
|
|
321
|
+
req.on("end", () => {
|
|
322
|
+
try {
|
|
323
|
+
const { path: filePath, content } = JSON.parse(body);
|
|
324
|
+
if (!filePath || content === void 0) {
|
|
325
|
+
res.statusCode = 400;
|
|
326
|
+
res.end(JSON.stringify({ error: "Missing path or content" }));
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const safePath = filePath.replace(/^\//, "");
|
|
330
|
+
if (safePath.includes("..")) {
|
|
331
|
+
res.statusCode = 403;
|
|
332
|
+
res.end(JSON.stringify({ error: "Access denied" }));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const absolutePath = (0, import_node_path.resolve)(process.cwd(), safePath);
|
|
336
|
+
import_node_fs.default.mkdirSync((0, import_node_path.dirname)(absolutePath), { recursive: true });
|
|
337
|
+
import_node_fs.default.writeFileSync(absolutePath, content, "utf-8");
|
|
338
|
+
res.setHeader("Content-Type", "application/json");
|
|
339
|
+
res.end(JSON.stringify({ success: true }));
|
|
340
|
+
} catch (e) {
|
|
341
|
+
console.error(import_picocolors.default.red(`[WebIDE] Error saving file:`), e);
|
|
342
|
+
res.statusCode = 500;
|
|
343
|
+
res.end(JSON.stringify({ error: String(e) }));
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
next();
|
|
349
|
+
});
|
|
350
|
+
server.middlewares.use(base, (0, import_sirv.default)(DIR_CLIENT, {
|
|
351
|
+
dev: true,
|
|
352
|
+
single: true,
|
|
353
|
+
setHeaders: (res, pathname) => {
|
|
354
|
+
if (pathname.endsWith(".ttf")) {
|
|
355
|
+
res.setHeader("Content-Type", "font/ttf");
|
|
356
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}));
|
|
360
|
+
const printUrls = server.printUrls;
|
|
361
|
+
server.printUrls = () => {
|
|
362
|
+
printUrls();
|
|
363
|
+
const colorUrl = (url) => import_picocolors.default.cyan(url.replace(/:(\d+)\//, (_, port) => `:${import_picocolors.default.bold(port)}/`));
|
|
364
|
+
console.log(` ${import_picocolors.default.green("\u279C")} ${import_picocolors.default.bold("Web IDE")}: ${colorUrl(`${server.config.server.https ? "https" : "http"}://localhost:${server.config.server.port}${base}`)}`);
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
}
|
package/dist/index.d.cts
ADDED
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import { dirname, join, resolve } from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import sirv from "sirv";
|
|
7
|
+
import pc from "picocolors";
|
|
8
|
+
|
|
9
|
+
// src/security.ts
|
|
10
|
+
import { randomBytes, createHmac } from "crypto";
|
|
11
|
+
var SecurityManager = class {
|
|
12
|
+
// Use a fixed secret or generate one. If random, tokens invalidate on restart (desired behavior).
|
|
13
|
+
secret = randomBytes(32).toString("hex");
|
|
14
|
+
constructor() {
|
|
15
|
+
}
|
|
16
|
+
createShare(options) {
|
|
17
|
+
let expiresAt = void 0;
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
if (options.validity === "1h") expiresAt = now + 3600 * 1e3;
|
|
20
|
+
if (options.validity === "24h") expiresAt = now + 24 * 3600 * 1e3;
|
|
21
|
+
const payload = {
|
|
22
|
+
acc: options.access === "readonly" ? "r" : "w",
|
|
23
|
+
iat: now
|
|
24
|
+
};
|
|
25
|
+
if (expiresAt) payload.exp = expiresAt;
|
|
26
|
+
if (options.password) {
|
|
27
|
+
payload.pwd = this.hashPassword(options.password);
|
|
28
|
+
}
|
|
29
|
+
const token = this.signToken(payload);
|
|
30
|
+
return { token };
|
|
31
|
+
}
|
|
32
|
+
getSession(token) {
|
|
33
|
+
const payload = this.verifyToken(token);
|
|
34
|
+
if (!payload) return void 0;
|
|
35
|
+
return {
|
|
36
|
+
token,
|
|
37
|
+
access: payload.acc === "r" ? "readonly" : "readwrite",
|
|
38
|
+
expiresAt: payload.exp || null,
|
|
39
|
+
passwordHash: payload.pwd,
|
|
40
|
+
createdAt: payload.iat
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// Check if request is allowed
|
|
44
|
+
validate(req, res) {
|
|
45
|
+
const remoteAddr = req.socket.remoteAddress;
|
|
46
|
+
const isLocal = remoteAddr === "127.0.0.1" || remoteAddr === "::1" || remoteAddr === "::ffff:127.0.0.1";
|
|
47
|
+
if (isLocal) return true;
|
|
48
|
+
let token = "";
|
|
49
|
+
const authHeader = req.headers["authorization"];
|
|
50
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
51
|
+
token = authHeader.substring(7);
|
|
52
|
+
}
|
|
53
|
+
if (!token) {
|
|
54
|
+
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
|
55
|
+
token = url.searchParams.get("token") || "";
|
|
56
|
+
}
|
|
57
|
+
if (!token) {
|
|
58
|
+
res.statusCode = 403;
|
|
59
|
+
res.end(JSON.stringify({ error: "Access denied: No token provided" }));
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const session = this.getSession(token);
|
|
63
|
+
if (!session) {
|
|
64
|
+
res.statusCode = 403;
|
|
65
|
+
res.end(JSON.stringify({ error: "Access denied: Invalid or expired token" }));
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
if (session.expiresAt && Date.now() > session.expiresAt) {
|
|
69
|
+
res.statusCode = 403;
|
|
70
|
+
res.end(JSON.stringify({ error: "Access denied: Token expired" }));
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
if (session.passwordHash) {
|
|
74
|
+
const url = req.url || "";
|
|
75
|
+
if (!url.includes("/auth/verify")) {
|
|
76
|
+
const providedPass = req.headers["x-webide-password"];
|
|
77
|
+
if (!providedPass || this.hashPassword(providedPass) !== session.passwordHash) {
|
|
78
|
+
res.statusCode = 401;
|
|
79
|
+
res.end(JSON.stringify({ error: "Password required", type: "password_required" }));
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (req.method === "POST" || req.method === "PUT" || req.method === "DELETE") {
|
|
85
|
+
if (req.url?.includes("/auth/verify")) return true;
|
|
86
|
+
if (session.access === "readonly") {
|
|
87
|
+
res.statusCode = 403;
|
|
88
|
+
res.end(JSON.stringify({ error: "Read-only access" }));
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
verifyPassword(token, password) {
|
|
95
|
+
const session = this.getSession(token);
|
|
96
|
+
if (!session || !session.passwordHash) return true;
|
|
97
|
+
return this.hashPassword(password) === session.passwordHash;
|
|
98
|
+
}
|
|
99
|
+
hashPassword(password) {
|
|
100
|
+
return createHmac("sha256", this.secret).update(password).digest("hex");
|
|
101
|
+
}
|
|
102
|
+
signToken(payload) {
|
|
103
|
+
const json = JSON.stringify(payload);
|
|
104
|
+
const b64 = Buffer.from(json).toString("base64url");
|
|
105
|
+
const signature = createHmac("sha256", this.secret).update(b64).digest("base64url");
|
|
106
|
+
return `${b64}.${signature}`;
|
|
107
|
+
}
|
|
108
|
+
verifyToken(token) {
|
|
109
|
+
try {
|
|
110
|
+
const [b64, signature] = token.split(".");
|
|
111
|
+
if (!b64 || !signature) return null;
|
|
112
|
+
const expectedSig = createHmac("sha256", this.secret).update(b64).digest("base64url");
|
|
113
|
+
if (signature !== expectedSig) return null;
|
|
114
|
+
const json = Buffer.from(b64, "base64url").toString();
|
|
115
|
+
return JSON.parse(json);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// src/index.ts
|
|
123
|
+
var DIR_DIST = typeof __dirname !== "undefined" ? __dirname : dirname(fileURLToPath(import.meta.url));
|
|
124
|
+
var DIR_CLIENT = resolve(DIR_DIST, "client");
|
|
125
|
+
function webIdePlugin(options = {}) {
|
|
126
|
+
const base = options.base || "/__webide__";
|
|
127
|
+
const apiBase = `${base}/api`;
|
|
128
|
+
const security = new SecurityManager();
|
|
129
|
+
return {
|
|
130
|
+
name: "vite-plugin-webide",
|
|
131
|
+
apply: "serve",
|
|
132
|
+
configureServer(server) {
|
|
133
|
+
server.middlewares.use(base, (req, res, next) => {
|
|
134
|
+
if (!req.url) return next();
|
|
135
|
+
if (req.url.startsWith("/api/share/create") && req.method === "POST") {
|
|
136
|
+
const remoteAddr = req.socket.remoteAddress;
|
|
137
|
+
const isLocal = remoteAddr === "127.0.0.1" || remoteAddr === "::1" || remoteAddr === "::ffff:127.0.0.1";
|
|
138
|
+
if (!isLocal) {
|
|
139
|
+
res.statusCode = 403;
|
|
140
|
+
res.end(JSON.stringify({ error: "Only localhost can create share links" }));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
let body = "";
|
|
144
|
+
req.on("data", (chunk) => body += chunk);
|
|
145
|
+
req.on("end", () => {
|
|
146
|
+
try {
|
|
147
|
+
const opts = JSON.parse(body);
|
|
148
|
+
const { token } = security.createShare(opts);
|
|
149
|
+
const interfaces = os.networkInterfaces();
|
|
150
|
+
let localIp = "localhost";
|
|
151
|
+
for (const key in interfaces) {
|
|
152
|
+
for (const iface of interfaces[key] || []) {
|
|
153
|
+
if (iface.family === "IPv4" && !iface.internal) {
|
|
154
|
+
localIp = iface.address;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (localIp !== "localhost") break;
|
|
159
|
+
}
|
|
160
|
+
const protocol = server.config.server.https ? "https" : "http";
|
|
161
|
+
const port = server.config.server.port;
|
|
162
|
+
const url = `${protocol}://${localIp}:${port}${base}?token=${token}`;
|
|
163
|
+
res.setHeader("Content-Type", "application/json");
|
|
164
|
+
res.end(JSON.stringify({ token, url }));
|
|
165
|
+
} catch (e) {
|
|
166
|
+
res.statusCode = 500;
|
|
167
|
+
res.end(JSON.stringify({ error: String(e) }));
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (req.url.startsWith("/api/auth/verify") && req.method === "POST") {
|
|
173
|
+
let body = "";
|
|
174
|
+
req.on("data", (chunk) => body += chunk);
|
|
175
|
+
req.on("end", () => {
|
|
176
|
+
try {
|
|
177
|
+
const { token, password } = JSON.parse(body);
|
|
178
|
+
const isValid = security.verifyPassword(token, password);
|
|
179
|
+
if (isValid) {
|
|
180
|
+
res.setHeader("Content-Type", "application/json");
|
|
181
|
+
res.end(JSON.stringify({ success: true }));
|
|
182
|
+
} else {
|
|
183
|
+
res.statusCode = 401;
|
|
184
|
+
res.end(JSON.stringify({ error: "Invalid password" }));
|
|
185
|
+
}
|
|
186
|
+
} catch (e) {
|
|
187
|
+
res.statusCode = 500;
|
|
188
|
+
res.end(JSON.stringify({ error: String(e) }));
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (req.url.startsWith("/api")) {
|
|
194
|
+
if (!security.validate(req, res)) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
next();
|
|
199
|
+
});
|
|
200
|
+
server.middlewares.use(apiBase, (req, res, next) => {
|
|
201
|
+
if (!req.url) return next();
|
|
202
|
+
if (req.url.startsWith("/api/auth/me")) {
|
|
203
|
+
let token = "";
|
|
204
|
+
const authHeader = req.headers["authorization"];
|
|
205
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
206
|
+
token = authHeader.substring(7);
|
|
207
|
+
} else {
|
|
208
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
209
|
+
token = url.searchParams.get("token") || "";
|
|
210
|
+
}
|
|
211
|
+
const session = security.getSession(token);
|
|
212
|
+
const access = session ? session.access : "readwrite";
|
|
213
|
+
res.setHeader("Content-Type", "application/json");
|
|
214
|
+
res.end(JSON.stringify({ access }));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (req.url.startsWith("/list")) {
|
|
218
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
219
|
+
const dirPath = url.searchParams.get("path") || "/";
|
|
220
|
+
try {
|
|
221
|
+
const absolutePath = resolve(process.cwd(), dirPath.replace(/^\//, ""));
|
|
222
|
+
if (!fs.existsSync(absolutePath)) {
|
|
223
|
+
res.statusCode = 404;
|
|
224
|
+
res.end(JSON.stringify({ error: "Path not found" }));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const items = fs.readdirSync(absolutePath, { withFileTypes: true }).map((dirent) => ({
|
|
228
|
+
name: dirent.name,
|
|
229
|
+
isFile: dirent.isFile(),
|
|
230
|
+
path: join(dirPath, dirent.name).replace(/\\/g, "/")
|
|
231
|
+
}));
|
|
232
|
+
res.setHeader("Content-Type", "application/json");
|
|
233
|
+
res.end(JSON.stringify(items));
|
|
234
|
+
} catch (e) {
|
|
235
|
+
console.error(pc.red(`[WebIDE] Error listing directory ${dirPath}:`), e);
|
|
236
|
+
res.statusCode = 500;
|
|
237
|
+
res.end(JSON.stringify({ error: String(e) }));
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (req.url.startsWith("/read")) {
|
|
242
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
243
|
+
const filePath = url.searchParams.get("path");
|
|
244
|
+
if (!filePath) {
|
|
245
|
+
res.statusCode = 400;
|
|
246
|
+
res.end(JSON.stringify({ error: "Missing path parameter" }));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const safePath = filePath.replace(/^\//, "");
|
|
251
|
+
if (safePath.includes("..")) {
|
|
252
|
+
res.statusCode = 403;
|
|
253
|
+
res.end(JSON.stringify({ error: "Access denied" }));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const absolutePath = resolve(process.cwd(), safePath);
|
|
257
|
+
if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
|
|
258
|
+
res.statusCode = 404;
|
|
259
|
+
res.end(JSON.stringify({ error: "File not found" }));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
263
|
+
const isImage = ["png", "jpg", "jpeg", "gif", "svg", "webp", "ico"].includes(ext || "");
|
|
264
|
+
if (isImage) {
|
|
265
|
+
const content = fs.readFileSync(absolutePath, "base64");
|
|
266
|
+
const mimeType = ext === "svg" ? "svg+xml" : ext;
|
|
267
|
+
res.setHeader("Content-Type", "application/json");
|
|
268
|
+
res.end(JSON.stringify({ content: `data:image/${mimeType};base64,${content}` }));
|
|
269
|
+
} else {
|
|
270
|
+
const content = fs.readFileSync(absolutePath, "utf-8");
|
|
271
|
+
res.setHeader("Content-Type", "application/json");
|
|
272
|
+
res.end(JSON.stringify({ content }));
|
|
273
|
+
}
|
|
274
|
+
} catch (e) {
|
|
275
|
+
console.error(pc.red(`[WebIDE] Error reading file ${filePath}:`), e);
|
|
276
|
+
res.statusCode = 500;
|
|
277
|
+
res.end(JSON.stringify({ error: String(e) }));
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (req.url.startsWith("/save") && req.method === "POST") {
|
|
282
|
+
let body = "";
|
|
283
|
+
req.on("data", (chunk) => {
|
|
284
|
+
body += chunk.toString();
|
|
285
|
+
});
|
|
286
|
+
req.on("end", () => {
|
|
287
|
+
try {
|
|
288
|
+
const { path: filePath, content } = JSON.parse(body);
|
|
289
|
+
if (!filePath || content === void 0) {
|
|
290
|
+
res.statusCode = 400;
|
|
291
|
+
res.end(JSON.stringify({ error: "Missing path or content" }));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const safePath = filePath.replace(/^\//, "");
|
|
295
|
+
if (safePath.includes("..")) {
|
|
296
|
+
res.statusCode = 403;
|
|
297
|
+
res.end(JSON.stringify({ error: "Access denied" }));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const absolutePath = resolve(process.cwd(), safePath);
|
|
301
|
+
fs.mkdirSync(dirname(absolutePath), { recursive: true });
|
|
302
|
+
fs.writeFileSync(absolutePath, content, "utf-8");
|
|
303
|
+
res.setHeader("Content-Type", "application/json");
|
|
304
|
+
res.end(JSON.stringify({ success: true }));
|
|
305
|
+
} catch (e) {
|
|
306
|
+
console.error(pc.red(`[WebIDE] Error saving file:`), e);
|
|
307
|
+
res.statusCode = 500;
|
|
308
|
+
res.end(JSON.stringify({ error: String(e) }));
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
next();
|
|
314
|
+
});
|
|
315
|
+
server.middlewares.use(base, sirv(DIR_CLIENT, {
|
|
316
|
+
dev: true,
|
|
317
|
+
single: true,
|
|
318
|
+
setHeaders: (res, pathname) => {
|
|
319
|
+
if (pathname.endsWith(".ttf")) {
|
|
320
|
+
res.setHeader("Content-Type", "font/ttf");
|
|
321
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}));
|
|
325
|
+
const printUrls = server.printUrls;
|
|
326
|
+
server.printUrls = () => {
|
|
327
|
+
printUrls();
|
|
328
|
+
const colorUrl = (url) => pc.cyan(url.replace(/:(\d+)\//, (_, port) => `:${pc.bold(port)}/`));
|
|
329
|
+
console.log(` ${pc.green("\u279C")} ${pc.bold("Web IDE")}: ${colorUrl(`${server.config.server.https ? "https" : "http"}://localhost:${server.config.server.port}${base}`)}`);
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
export {
|
|
335
|
+
webIdePlugin as default
|
|
336
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vite-plugin-webide",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A Vite plugin that provides a web-based IDE for your project.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
},
|
|
15
|
+
"./client": {
|
|
16
|
+
"types": "./client.d.ts"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"client.d.ts"
|
|
22
|
+
],
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"monaco-editor-nls": "^3.1.0",
|
|
25
|
+
"picocolors": "^1.0.0",
|
|
26
|
+
"sirv": "^2.0.4"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@changesets/cli": "^2.27.9",
|
|
30
|
+
"@types/node": "^20.11.0",
|
|
31
|
+
"@vitejs/plugin-vue": "^5.0.3",
|
|
32
|
+
"monaco-editor": "^0.45.0",
|
|
33
|
+
"rimraf": "^5.0.5",
|
|
34
|
+
"tsup": "^8.0.1",
|
|
35
|
+
"typescript": "^5.3.3",
|
|
36
|
+
"vite": "^5.0.11",
|
|
37
|
+
"vue": "^3.4.15"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"clean": "rimraf dist .turbo",
|
|
41
|
+
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
|
42
|
+
"build": "pnpm run clean && tsup",
|
|
43
|
+
"dev": "tsup --watch",
|
|
44
|
+
"coverage": "vitest run --coverage",
|
|
45
|
+
"docs": "typedoc",
|
|
46
|
+
"changeset": "changeset",
|
|
47
|
+
"release": "changeset version && pnpm install --ignore-scripts && pnpm run build && changeset publish"
|
|
48
|
+
}
|
|
49
|
+
}
|