pinokiod 7.2.6 → 7.2.8
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/kernel/api/index.js +2 -0
- package/kernel/api/shell_run_template.js +273 -0
- package/kernel/shell.js +40 -2
- package/package.json +1 -1
- package/server/index.js +65 -5
- package/server/lib/drafts.js +376 -0
- package/server/lib/workspace_catalog.js +151 -0
- package/server/lib/workspace_runtime.js +390 -0
- package/server/public/common.js +8 -0
- package/server/public/drafts.js +632 -0
- package/server/routes/draft_import.js +469 -0
- package/server/routes/workspaces.js +44 -0
- package/server/socket.js +22 -11
- package/server/views/app.ejs +13 -0
- package/server/views/partials/main_sidebar.ejs +1 -0
- package/server/views/partials/workspace_row.ejs +61 -0
- package/server/views/terminal.ejs +6 -0
- package/server/views/terminals.ejs +1 -0
- package/server/views/workspaces.ejs +812 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
const express = require("express")
|
|
2
|
+
const fs = require("fs")
|
|
3
|
+
const path = require("path")
|
|
4
|
+
const axios = require("axios")
|
|
5
|
+
const FormData = require("form-data")
|
|
6
|
+
const mime = require("mime-types")
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MAX_FILES = 10
|
|
9
|
+
const DEFAULT_MAX_FILE_BYTES = 25 * 1024 * 1024
|
|
10
|
+
|
|
11
|
+
const asyncHandler = (fn) => (req, res, next) => {
|
|
12
|
+
Promise.resolve(fn(req, res, next)).catch(next)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const escapeHtml = (value) => String(value || "")
|
|
16
|
+
.replace(/&/g, "&")
|
|
17
|
+
.replace(/</g, "<")
|
|
18
|
+
.replace(/>/g, ">")
|
|
19
|
+
.replace(/"/g, """)
|
|
20
|
+
|
|
21
|
+
function renderMessage(res, status, title, message) {
|
|
22
|
+
res.status(status).send(`<!doctype html>
|
|
23
|
+
<html>
|
|
24
|
+
<head>
|
|
25
|
+
<meta charset="utf-8">
|
|
26
|
+
<title>${escapeHtml(title)}</title>
|
|
27
|
+
<style>
|
|
28
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 40px; color: #111827; }
|
|
29
|
+
.box { max-width: 680px; border: 1px solid #d1d5db; border-radius: 8px; padding: 18px; }
|
|
30
|
+
h1 { margin: 0 0 8px; font-size: 22px; }
|
|
31
|
+
p { margin: 0; color: #4b5563; line-height: 1.5; }
|
|
32
|
+
</style>
|
|
33
|
+
</head>
|
|
34
|
+
<body>
|
|
35
|
+
<div class="box">
|
|
36
|
+
<h1>${escapeHtml(title)}</h1>
|
|
37
|
+
<p>${escapeHtml(message)}</p>
|
|
38
|
+
</div>
|
|
39
|
+
</body>
|
|
40
|
+
</html>`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderImportLauncher(res, { authorizeUrl, draftId, registryOrigin, autoOpen }) {
|
|
44
|
+
res.status(200).send(`<!doctype html>
|
|
45
|
+
<html>
|
|
46
|
+
<head>
|
|
47
|
+
<meta charset="utf-8">
|
|
48
|
+
<title>Import draft</title>
|
|
49
|
+
<style>
|
|
50
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 40px; color: #111827; background: #f8fafc; }
|
|
51
|
+
.box { max-width: 680px; border: 1px solid #d1d5db; border-radius: 8px; padding: 18px; background: white; box-shadow: 0 16px 42px rgba(15, 23, 42, 0.08); }
|
|
52
|
+
h1 { margin: 0 0 8px; font-size: 22px; }
|
|
53
|
+
p { margin: 0 0 14px; color: #4b5563; line-height: 1.5; }
|
|
54
|
+
button, a.button { display: inline-flex; align-items: center; justify-content: center; min-height: 34px; border: 1px solid #111827; border-radius: 6px; background: #111827; color: white; padding: 7px 12px; font-weight: 700; text-decoration: none; cursor: pointer; }
|
|
55
|
+
.muted { color: #6b7280; font-size: 13px; }
|
|
56
|
+
</style>
|
|
57
|
+
</head>
|
|
58
|
+
<body>
|
|
59
|
+
<div class="box">
|
|
60
|
+
<h1>Import draft</h1>
|
|
61
|
+
<p id="status">${autoOpen ? "Opening the registry authorization window..." : "Click Open registry to authorize the import."}</p>
|
|
62
|
+
<button id="open" type="button">Open registry</button>
|
|
63
|
+
<div class="muted" style="margin-top:12px;">Keep this Pinokio window open until the registry draft editor opens.</div>
|
|
64
|
+
</div>
|
|
65
|
+
<script>
|
|
66
|
+
window.__PINOKIO_DRAFT_IMPORT_VERSION = "metadata-b64";
|
|
67
|
+
const authorizeUrl = ${JSON.stringify(authorizeUrl)};
|
|
68
|
+
const draftId = ${JSON.stringify(draftId)};
|
|
69
|
+
const registryOrigin = ${JSON.stringify(registryOrigin)};
|
|
70
|
+
const autoOpen = ${JSON.stringify(Boolean(autoOpen))};
|
|
71
|
+
const statusEl = document.getElementById("status");
|
|
72
|
+
const openButton = document.getElementById("open");
|
|
73
|
+
let registryWindow = null;
|
|
74
|
+
|
|
75
|
+
function setStatus(message) {
|
|
76
|
+
statusEl.textContent = message;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function openRegistry() {
|
|
80
|
+
registryWindow = window.open(authorizeUrl, "pinokioRegistryDraftImport", "popup,width=760,height=820");
|
|
81
|
+
if (!registryWindow) {
|
|
82
|
+
setStatus("The registry window was blocked. Click Open registry to continue.");
|
|
83
|
+
} else {
|
|
84
|
+
setStatus("Authorize the import in the registry window.");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function completeImport(payload) {
|
|
89
|
+
setStatus("Uploading draft to the registry...");
|
|
90
|
+
const response = await fetch("/registry/draft-import/complete", {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { "Content-Type": "application/json" },
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
draft: draftId,
|
|
95
|
+
token: payload.token,
|
|
96
|
+
registry: payload.registry,
|
|
97
|
+
app: payload.app || ""
|
|
98
|
+
})
|
|
99
|
+
});
|
|
100
|
+
const data = await response.json().catch(() => null);
|
|
101
|
+
if (!response.ok || !data || !data.editUrl) {
|
|
102
|
+
const detail = data && data.status ? " (" + data.status + ")" : "";
|
|
103
|
+
throw new Error(((data && data.error) || "Import failed.") + detail);
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
if (registryWindow && !registryWindow.closed) registryWindow.close();
|
|
107
|
+
} catch (_) {}
|
|
108
|
+
window.location.href = data.editUrl;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
window.addEventListener("message", (event) => {
|
|
112
|
+
if (event.origin !== registryOrigin) return;
|
|
113
|
+
const payload = event.data || {};
|
|
114
|
+
if (payload.type !== "pinokio:draft-import-token" || !payload.token || !payload.registry) return;
|
|
115
|
+
completeImport(payload).catch((error) => {
|
|
116
|
+
setStatus(error && error.message ? error.message : "Import failed.");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
openButton.addEventListener("click", openRegistry);
|
|
121
|
+
if (autoOpen) {
|
|
122
|
+
window.setTimeout(openRegistry, 100);
|
|
123
|
+
}
|
|
124
|
+
</script>
|
|
125
|
+
</body>
|
|
126
|
+
</html>`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizeRegistryBase(raw, fallback) {
|
|
130
|
+
const value = String(raw || fallback || "").trim()
|
|
131
|
+
if (!value) return ""
|
|
132
|
+
try {
|
|
133
|
+
const url = new URL(value)
|
|
134
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return ""
|
|
135
|
+
url.hash = ""
|
|
136
|
+
url.search = ""
|
|
137
|
+
return url.toString().replace(/\/$/, "")
|
|
138
|
+
} catch (_) {
|
|
139
|
+
return ""
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function requestOrigin(req) {
|
|
144
|
+
const host = req.get("host") || "localhost:42000"
|
|
145
|
+
return `${req.protocol || "http"}://${host}`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isExternalRef(value) {
|
|
149
|
+
return /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(value)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeMarkdownRef(value) {
|
|
153
|
+
const raw = String(value || "").trim().replace(/^<|>$/g, "")
|
|
154
|
+
if (!raw || raw.includes("\0") || isExternalRef(raw) || path.isAbsolute(raw)) {
|
|
155
|
+
return ""
|
|
156
|
+
}
|
|
157
|
+
const withoutHash = raw.split("#")[0]
|
|
158
|
+
const withoutQuery = withoutHash.split("?")[0]
|
|
159
|
+
if (!withoutQuery) {
|
|
160
|
+
return ""
|
|
161
|
+
}
|
|
162
|
+
let decoded = withoutQuery
|
|
163
|
+
try {
|
|
164
|
+
decoded = decodeURIComponent(withoutQuery)
|
|
165
|
+
} catch (_) {
|
|
166
|
+
}
|
|
167
|
+
const normalized = path.posix.normalize(decoded.replace(/\\/g, "/"))
|
|
168
|
+
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
169
|
+
return ""
|
|
170
|
+
}
|
|
171
|
+
return normalized
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function collectMarkdownRefs(markdown) {
|
|
175
|
+
const refs = []
|
|
176
|
+
const seen = new Set()
|
|
177
|
+
const patterns = [
|
|
178
|
+
/!\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g,
|
|
179
|
+
/\[(?:video|audio|media|image|screenshot|file|asset)[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/gi,
|
|
180
|
+
/\[[^\]]+]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g
|
|
181
|
+
]
|
|
182
|
+
for (const pattern of patterns) {
|
|
183
|
+
let match = null
|
|
184
|
+
while ((match = pattern.exec(markdown))) {
|
|
185
|
+
const ref = normalizeMarkdownRef(match[1])
|
|
186
|
+
if (!ref || seen.has(ref)) continue
|
|
187
|
+
seen.add(ref)
|
|
188
|
+
refs.push(ref)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return refs
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function extractTitleAndBody(markdown, fallbackTitle) {
|
|
195
|
+
const lines = String(markdown || "").split(/\r?\n/)
|
|
196
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
197
|
+
const match = lines[i].match(/^#\s+(.+?)\s*#*\s*$/)
|
|
198
|
+
if (!match || !match[1]) continue
|
|
199
|
+
const title = match[1].replace(/\s+/g, " ").trim().slice(0, 160)
|
|
200
|
+
const bodyLines = [...lines.slice(0, i), ...lines.slice(i + 1)]
|
|
201
|
+
while (bodyLines.length && !bodyLines[0].trim()) bodyLines.shift()
|
|
202
|
+
return { title: title || fallbackTitle, body: bodyLines.join("\n").trim() }
|
|
203
|
+
}
|
|
204
|
+
return { title: fallbackTitle, body: String(markdown || "").trim() }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function findDraftById(drafts, id) {
|
|
208
|
+
const normalized = String(id || "").trim()
|
|
209
|
+
if (!normalized) return null
|
|
210
|
+
const items = await drafts.listPending({})
|
|
211
|
+
return (items || []).find((item) => item && item.id === normalized) || null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function describeMedia(markdown, baseDir) {
|
|
215
|
+
const refs = collectMarkdownRefs(markdown)
|
|
216
|
+
const media = []
|
|
217
|
+
for (const ref of refs) {
|
|
218
|
+
const filePath = path.resolve(baseDir, ref)
|
|
219
|
+
const relative = path.relative(baseDir, filePath)
|
|
220
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) continue
|
|
221
|
+
const stats = await fs.promises.stat(filePath).catch(() => null)
|
|
222
|
+
if (!stats || !stats.isFile()) {
|
|
223
|
+
media.push({ ref, path: filePath, exists: false, bytes: 0 })
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
media.push({ ref, path: filePath, exists: true, bytes: stats.size })
|
|
227
|
+
}
|
|
228
|
+
return media
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function buildDraftBundle(item, query = {}) {
|
|
232
|
+
const markdown = await fs.promises.readFile(item.postPath, "utf8")
|
|
233
|
+
const resultDir = path.dirname(item.postPath)
|
|
234
|
+
const titleFallback = item.title || (item.workspaceName ? `Draft for ${item.workspaceName}` : "Draft")
|
|
235
|
+
const { title, body } = extractTitleAndBody(markdown, titleFallback)
|
|
236
|
+
const media = await describeMedia(markdown, resultDir)
|
|
237
|
+
return {
|
|
238
|
+
title,
|
|
239
|
+
body,
|
|
240
|
+
appSlug: String(query.app || "").trim(),
|
|
241
|
+
media
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function preflightBundle(bundle, options = {}) {
|
|
246
|
+
const maxFiles = Number(options.maxFiles || DEFAULT_MAX_FILES)
|
|
247
|
+
const maxFileBytes = Number(options.maxFileBytes || DEFAULT_MAX_FILE_BYTES)
|
|
248
|
+
if (!bundle.title) {
|
|
249
|
+
return "Draft title is missing."
|
|
250
|
+
}
|
|
251
|
+
if (bundle.media.length > maxFiles) {
|
|
252
|
+
return `Draft has ${bundle.media.length} media files. The registry limit is ${maxFiles}.`
|
|
253
|
+
}
|
|
254
|
+
const missing = bundle.media.filter((item) => !item.exists)
|
|
255
|
+
if (missing.length > 0) {
|
|
256
|
+
return `Draft references missing media: ${missing.map((item) => item.ref).join(", ")}`
|
|
257
|
+
}
|
|
258
|
+
const oversized = bundle.media.find((item) => item.bytes > maxFileBytes)
|
|
259
|
+
if (oversized) {
|
|
260
|
+
return `Media file is too large: ${oversized.ref}. The per-file limit is ${Math.round(maxFileBytes / 1024 / 1024)} MB.`
|
|
261
|
+
}
|
|
262
|
+
return ""
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function uploadBundle(registryBase, token, bundle) {
|
|
266
|
+
const form = new FormData()
|
|
267
|
+
const metadata = JSON.stringify({
|
|
268
|
+
title: bundle.title,
|
|
269
|
+
body: bundle.body,
|
|
270
|
+
app: bundle.appSlug || "",
|
|
271
|
+
media: bundle.media.map((item) => ({ path: item.ref }))
|
|
272
|
+
})
|
|
273
|
+
form.append("metadata_b64", Buffer.from(metadata, "utf8").toString("base64"))
|
|
274
|
+
for (const item of bundle.media) {
|
|
275
|
+
form.append("files", fs.createReadStream(item.path), {
|
|
276
|
+
filename: path.basename(item.ref),
|
|
277
|
+
contentType: mime.lookup(item.path) || "application/octet-stream",
|
|
278
|
+
knownLength: item.bytes
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
const endpoint = `${registryBase}/registry-bridge/draft-imports`
|
|
282
|
+
const headers = {
|
|
283
|
+
Authorization: `Bearer ${token}`,
|
|
284
|
+
...form.getHeaders()
|
|
285
|
+
}
|
|
286
|
+
const contentLength = await new Promise((resolve) => {
|
|
287
|
+
form.getLength((error, length) => resolve(error ? null : length))
|
|
288
|
+
})
|
|
289
|
+
if (Number.isFinite(contentLength)) {
|
|
290
|
+
headers["Content-Length"] = contentLength
|
|
291
|
+
}
|
|
292
|
+
console.log("[draft-import] request", {
|
|
293
|
+
endpoint,
|
|
294
|
+
media: bundle.media.length,
|
|
295
|
+
contentLength: Number.isFinite(contentLength) ? contentLength : null
|
|
296
|
+
})
|
|
297
|
+
const response = await axios.post(endpoint, form, {
|
|
298
|
+
timeout: 180000,
|
|
299
|
+
maxContentLength: Infinity,
|
|
300
|
+
maxBodyLength: Infinity,
|
|
301
|
+
validateStatus: () => true,
|
|
302
|
+
headers
|
|
303
|
+
})
|
|
304
|
+
if (response.status < 200 || response.status >= 300) {
|
|
305
|
+
const error = new Error(
|
|
306
|
+
response.data && response.data.error
|
|
307
|
+
? String(response.data.error)
|
|
308
|
+
: `Registry upload failed with status ${response.status}.`
|
|
309
|
+
)
|
|
310
|
+
error.status = response.status
|
|
311
|
+
error.registryEndpoint = endpoint
|
|
312
|
+
error.responseData = response.data
|
|
313
|
+
throw error
|
|
314
|
+
}
|
|
315
|
+
return response.data || {}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function uploadDraftFromRequest(drafts, query, token, registryBase, options = {}) {
|
|
319
|
+
const item = await findDraftById(drafts, query.draft)
|
|
320
|
+
if (!item) {
|
|
321
|
+
const error = new Error("The local draft is no longer available.")
|
|
322
|
+
error.status = 404
|
|
323
|
+
throw error
|
|
324
|
+
}
|
|
325
|
+
const bundle = await buildDraftBundle(item, query)
|
|
326
|
+
const problem = preflightBundle(bundle, options)
|
|
327
|
+
if (problem) {
|
|
328
|
+
const error = new Error(problem)
|
|
329
|
+
error.status = 400
|
|
330
|
+
throw error
|
|
331
|
+
}
|
|
332
|
+
return uploadBundle(registryBase, token, bundle)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function registerDraftImportRoutes(app, options = {}) {
|
|
336
|
+
const drafts = options.drafts
|
|
337
|
+
if (!drafts) {
|
|
338
|
+
throw new Error("drafts is required")
|
|
339
|
+
}
|
|
340
|
+
const defaultRegistryUrl = options.defaultRegistryUrl || "https://beta.pinokio.co"
|
|
341
|
+
const router = express.Router()
|
|
342
|
+
|
|
343
|
+
router.get("/registry/draft-import/authorize-url", asyncHandler(async (req, res) => {
|
|
344
|
+
const item = await findDraftById(drafts, req.query.draft)
|
|
345
|
+
if (!item) {
|
|
346
|
+
return res.status(404).json({ error: "The local draft is no longer available." })
|
|
347
|
+
}
|
|
348
|
+
const bundle = await buildDraftBundle(item, req.query)
|
|
349
|
+
const problem = preflightBundle(bundle, options)
|
|
350
|
+
if (problem) {
|
|
351
|
+
return res.status(400).json({ error: problem })
|
|
352
|
+
}
|
|
353
|
+
const registryBase = normalizeRegistryBase(req.query.registry, defaultRegistryUrl)
|
|
354
|
+
if (!registryBase) {
|
|
355
|
+
return res.status(400).json({ error: "The registry URL is invalid." })
|
|
356
|
+
}
|
|
357
|
+
const authorizeUrl = new URL("/draft-import/authorize", registryBase)
|
|
358
|
+
authorizeUrl.searchParams.set("handoff", "post_message")
|
|
359
|
+
authorizeUrl.searchParams.set("origin", requestOrigin(req))
|
|
360
|
+
authorizeUrl.searchParams.set("wait", "1")
|
|
361
|
+
if (bundle.appSlug) authorizeUrl.searchParams.set("app", bundle.appSlug)
|
|
362
|
+
res.setHeader("Cache-Control", "no-store")
|
|
363
|
+
return res.json({
|
|
364
|
+
draftId: item.id,
|
|
365
|
+
authorizeUrl: authorizeUrl.toString(),
|
|
366
|
+
registryOrigin: new URL(registryBase).origin
|
|
367
|
+
})
|
|
368
|
+
}))
|
|
369
|
+
|
|
370
|
+
router.get("/registry/draft-import/start", asyncHandler(async (req, res) => {
|
|
371
|
+
const item = await findDraftById(drafts, req.query.draft)
|
|
372
|
+
if (!item) {
|
|
373
|
+
return renderMessage(res, 404, "Draft not found", "The local draft is no longer available.")
|
|
374
|
+
}
|
|
375
|
+
const bundle = await buildDraftBundle(item, req.query)
|
|
376
|
+
const problem = preflightBundle(bundle, options)
|
|
377
|
+
if (problem) {
|
|
378
|
+
return renderMessage(res, 400, "Draft is not ready", problem)
|
|
379
|
+
}
|
|
380
|
+
const registryBase = normalizeRegistryBase(req.query.registry, defaultRegistryUrl)
|
|
381
|
+
if (!registryBase) {
|
|
382
|
+
return renderMessage(res, 400, "Registry unavailable", "The registry URL is invalid.")
|
|
383
|
+
}
|
|
384
|
+
const authorizeUrl = new URL("/draft-import/authorize", registryBase)
|
|
385
|
+
authorizeUrl.searchParams.set("handoff", "post_message")
|
|
386
|
+
authorizeUrl.searchParams.set("origin", requestOrigin(req))
|
|
387
|
+
if (bundle.appSlug) authorizeUrl.searchParams.set("app", bundle.appSlug)
|
|
388
|
+
res.setHeader("Cache-Control", "no-store")
|
|
389
|
+
res.setHeader("Cross-Origin-Opener-Policy", "unsafe-none")
|
|
390
|
+
return renderImportLauncher(res, {
|
|
391
|
+
authorizeUrl: authorizeUrl.toString(),
|
|
392
|
+
draftId: item.id,
|
|
393
|
+
registryOrigin: new URL(registryBase).origin,
|
|
394
|
+
autoOpen: req.query.auto === "1"
|
|
395
|
+
})
|
|
396
|
+
}))
|
|
397
|
+
|
|
398
|
+
router.post("/registry/draft-import/complete", asyncHandler(async (req, res) => {
|
|
399
|
+
const token = String(req.body && req.body.token || "").trim()
|
|
400
|
+
if (!token) {
|
|
401
|
+
return res.status(400).json({ error: "Missing registry token." })
|
|
402
|
+
}
|
|
403
|
+
const registryBase = normalizeRegistryBase(req.body && req.body.registry, defaultRegistryUrl)
|
|
404
|
+
if (!registryBase) {
|
|
405
|
+
return res.status(400).json({ error: "The registry URL is invalid." })
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
console.log("[draft-import] uploading", {
|
|
409
|
+
draft: req.body && req.body.draft,
|
|
410
|
+
registry: registryBase,
|
|
411
|
+
app: req.body && req.body.app ? String(req.body.app) : ""
|
|
412
|
+
})
|
|
413
|
+
const result = await uploadDraftFromRequest(
|
|
414
|
+
drafts,
|
|
415
|
+
{ draft: req.body && req.body.draft, app: req.body && req.body.app },
|
|
416
|
+
token,
|
|
417
|
+
registryBase,
|
|
418
|
+
options
|
|
419
|
+
)
|
|
420
|
+
if (result && result.editUrl) {
|
|
421
|
+
return res.json({ ok: true, editUrl: String(result.editUrl) })
|
|
422
|
+
}
|
|
423
|
+
return res.json({ ok: true, editUrl: registryBase })
|
|
424
|
+
} catch (error) {
|
|
425
|
+
const response = error && error.response
|
|
426
|
+
const status = response && response.status ? response.status : (error && error.status ? error.status : 500)
|
|
427
|
+
const endpoint = error && error.registryEndpoint ? error.registryEndpoint : `${registryBase}/registry-bridge/draft-imports`
|
|
428
|
+
const responseData = response && response.data ? response.data : (error && error.responseData ? error.responseData : null)
|
|
429
|
+
console.warn("[draft-import] upload failed", {
|
|
430
|
+
status,
|
|
431
|
+
endpoint,
|
|
432
|
+
error: error && error.message ? error.message : "Upload failed.",
|
|
433
|
+
response: typeof responseData === "string" ? responseData.slice(0, 500) : responseData
|
|
434
|
+
})
|
|
435
|
+
const message = response && response.data && response.data.error
|
|
436
|
+
? response.data.error
|
|
437
|
+
: (error && error.message ? error.message : "Upload failed.")
|
|
438
|
+
return res.status(status).json({ error: message, status, endpoint })
|
|
439
|
+
}
|
|
440
|
+
}))
|
|
441
|
+
|
|
442
|
+
router.get("/registry/draft-import/callback", asyncHandler(async (req, res) => {
|
|
443
|
+
const token = String(req.query.token || "").trim()
|
|
444
|
+
if (!token) {
|
|
445
|
+
return renderMessage(res, 400, "Missing token", "The registry did not return an import token.")
|
|
446
|
+
}
|
|
447
|
+
const registryBase = normalizeRegistryBase(req.query.registry, defaultRegistryUrl)
|
|
448
|
+
if (!registryBase) {
|
|
449
|
+
return renderMessage(res, 400, "Registry unavailable", "The registry URL is invalid.")
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
const result = await uploadDraftFromRequest(drafts, req.query, token, registryBase, options)
|
|
453
|
+
if (result && result.editUrl) {
|
|
454
|
+
return res.redirect(String(result.editUrl))
|
|
455
|
+
}
|
|
456
|
+
return renderMessage(res, 200, "Draft imported", "The registry accepted the draft.")
|
|
457
|
+
} catch (error) {
|
|
458
|
+
const response = error && error.response
|
|
459
|
+
const message = response && response.data && response.data.error
|
|
460
|
+
? response.data.error
|
|
461
|
+
: (error && error.message ? error.message : "Upload failed.")
|
|
462
|
+
return renderMessage(res, response && response.status ? response.status : 500, "Import failed", message)
|
|
463
|
+
}
|
|
464
|
+
}))
|
|
465
|
+
|
|
466
|
+
app.use(router)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
module.exports = registerDraftImportRoutes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const express = require("express")
|
|
2
|
+
|
|
3
|
+
function registerWorkspacesRoutes(app, options = {}) {
|
|
4
|
+
const {
|
|
5
|
+
workspaceCatalog,
|
|
6
|
+
composePeerAccessPayload,
|
|
7
|
+
getTheme,
|
|
8
|
+
getPeers,
|
|
9
|
+
getCurrentHost,
|
|
10
|
+
getPortal,
|
|
11
|
+
} = options
|
|
12
|
+
|
|
13
|
+
if (!workspaceCatalog) {
|
|
14
|
+
throw new Error("workspaceCatalog is required")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const router = express.Router()
|
|
18
|
+
|
|
19
|
+
router.get("/workspaces", async (req, res, next) => {
|
|
20
|
+
try {
|
|
21
|
+
const catalog = await workspaceCatalog.list({ sort: req.query.sort })
|
|
22
|
+
res.render("workspaces", {
|
|
23
|
+
title: "Workspaces",
|
|
24
|
+
sidebarSelected: "workspaces",
|
|
25
|
+
workspaceCatalog: catalog,
|
|
26
|
+
theme: getTheme ? getTheme(req) : null,
|
|
27
|
+
peers: getPeers ? getPeers() : [],
|
|
28
|
+
currentHost: getCurrentHost ? getCurrentHost(req) : null,
|
|
29
|
+
portal: getPortal ? getPortal(req) : null,
|
|
30
|
+
peerAccess: composePeerAccessPayload ? composePeerAccessPayload(req) : null,
|
|
31
|
+
})
|
|
32
|
+
} catch (err) {
|
|
33
|
+
next(err)
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
router.get("/activity", (req, res) => {
|
|
38
|
+
res.redirect("/workspaces")
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
app.use(router)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = registerWorkspacesRoutes
|
package/server/socket.js
CHANGED
|
@@ -284,7 +284,7 @@ class Socket {
|
|
|
284
284
|
} else {
|
|
285
285
|
let buf = this.buffer[id]
|
|
286
286
|
let sh = this.active_shell[id]
|
|
287
|
-
this.subscribe(ws, id, buf, sh)
|
|
287
|
+
this.subscribe(ws, id, buf, sh, req)
|
|
288
288
|
if (req.mode !== "listen") {
|
|
289
289
|
// Run only if currently not running
|
|
290
290
|
if (!this.parent.kernel.api.running[id]) {
|
|
@@ -308,7 +308,7 @@ class Socket {
|
|
|
308
308
|
if (req.id) {
|
|
309
309
|
let buf = this.buffer[req.id]
|
|
310
310
|
let sh = this.active_shell[req.id]
|
|
311
|
-
this.subscribe(ws, req.id, buf, sh)
|
|
311
|
+
this.subscribe(ws, req.id, buf, sh, req)
|
|
312
312
|
if (req.mode === "listen") {
|
|
313
313
|
return
|
|
314
314
|
}
|
|
@@ -345,13 +345,7 @@ class Socket {
|
|
|
345
345
|
// Mark local client sockets by IP matching any local address
|
|
346
346
|
try {
|
|
347
347
|
const ip = ws._ip || ''
|
|
348
|
-
|
|
349
|
-
if (!addr || typeof addr !== 'string') return false
|
|
350
|
-
if (this.localAddresses.has(addr)) return true
|
|
351
|
-
const v = addr.trim().toLowerCase()
|
|
352
|
-
return v.startsWith('::ffff:127.') || v.startsWith('127.')
|
|
353
|
-
}
|
|
354
|
-
ws._isLocalClient = isLocal(ip)
|
|
348
|
+
ws._isLocalClient = this.isLocalAddress(ip)
|
|
355
349
|
if (ws._isLocalClient && ws._deviceId) {
|
|
356
350
|
this.localDeviceIds.add(ws._deviceId)
|
|
357
351
|
}
|
|
@@ -431,18 +425,22 @@ class Socket {
|
|
|
431
425
|
this.old_buffer = structuredClone(this.buffer)
|
|
432
426
|
}, 5000)
|
|
433
427
|
}
|
|
434
|
-
subscribe(ws, id, buf, sh) {
|
|
428
|
+
subscribe(ws, id, buf, sh, req = {}) {
|
|
435
429
|
let resolvedShellId = sh || null
|
|
436
430
|
let resolvedState = buf
|
|
437
431
|
let hasState = typeof resolvedState === "string" ? resolvedState.length > 0 : Boolean(resolvedState)
|
|
432
|
+
let resolvedShell = null
|
|
438
433
|
if ((!resolvedShellId || !hasState) && this.parent && this.parent.kernel && this.parent.kernel.shell && Array.isArray(this.parent.kernel.shell.shells)) {
|
|
439
|
-
const
|
|
434
|
+
const directShell = this.parent.kernel.shell.get(id)
|
|
435
|
+
const liveDirectShell = directShell && directShell.done !== true ? directShell : null
|
|
436
|
+
const groupedShell = liveDirectShell || this.parent.kernel.shell.shells.find((candidate) => {
|
|
440
437
|
return candidate
|
|
441
438
|
&& candidate.done !== true
|
|
442
439
|
&& typeof candidate.group === "string"
|
|
443
440
|
&& candidate.group === id
|
|
444
441
|
})
|
|
445
442
|
if (groupedShell) {
|
|
443
|
+
resolvedShell = groupedShell
|
|
446
444
|
if (!resolvedShellId) {
|
|
447
445
|
resolvedShellId = groupedShell.id
|
|
448
446
|
}
|
|
@@ -455,6 +453,12 @@ class Socket {
|
|
|
455
453
|
}
|
|
456
454
|
}
|
|
457
455
|
}
|
|
456
|
+
if (!resolvedShell && resolvedShellId && this.parent && this.parent.kernel && this.parent.kernel.shell) {
|
|
457
|
+
resolvedShell = this.parent.kernel.shell.get(resolvedShellId)
|
|
458
|
+
}
|
|
459
|
+
if (resolvedShell && req && req.input) {
|
|
460
|
+
resolvedShell.input = true
|
|
461
|
+
}
|
|
458
462
|
if (this.parent.kernel.api.running[id] || resolvedShellId || hasState) {
|
|
459
463
|
ws.send(JSON.stringify({
|
|
460
464
|
type: "connect",
|
|
@@ -652,6 +656,13 @@ class Socket {
|
|
|
652
656
|
return this.localDeviceIds.has(deviceId)
|
|
653
657
|
}
|
|
654
658
|
|
|
659
|
+
isLocalAddress(addr) {
|
|
660
|
+
if (!addr || typeof addr !== 'string') return false
|
|
661
|
+
if (this.localAddresses.has(addr)) return true
|
|
662
|
+
const v = addr.trim().toLowerCase()
|
|
663
|
+
return v === 'localhost' || v === '::1' || v.startsWith('::ffff:127.') || v.startsWith('127.')
|
|
664
|
+
}
|
|
665
|
+
|
|
655
666
|
ensureNotificationBridge() {
|
|
656
667
|
if (this.notificationBridgeDispose) {
|
|
657
668
|
return
|
package/server/views/app.ejs
CHANGED
|
@@ -5222,6 +5222,9 @@ header.navheader .mode-selector .community-mode-toggle {
|
|
|
5222
5222
|
<a class='btn mobile-sheet-action' href="/home" aria-label="Home" title="Home">
|
|
5223
5223
|
<i class="fa-solid fa-house"></i>
|
|
5224
5224
|
</a>
|
|
5225
|
+
<a class='btn mobile-sheet-action' href="/workspaces" aria-label="Workspaces" title="Workspaces">
|
|
5226
|
+
<i class="fa-solid fa-folder-tree"></i>
|
|
5227
|
+
</a>
|
|
5225
5228
|
<button type='button' class='btn mobile-sheet-action' data-mobile-proxy="#refresh-page" data-mobile-close-menu="true" aria-label="Refresh" title="Refresh">
|
|
5226
5229
|
<i class="fa-solid fa-rotate-right"></i>
|
|
5227
5230
|
</button>
|
|
@@ -5290,6 +5293,13 @@ header.navheader .mode-selector .community-mode-toggle {
|
|
|
5290
5293
|
</div>
|
|
5291
5294
|
</div>
|
|
5292
5295
|
<div class='menu-actions'>
|
|
5296
|
+
<a id='workspaces-tab' href="/workspaces" class="btn header-item" data-tippy-content="Workspaces">
|
|
5297
|
+
<div class='tab'>
|
|
5298
|
+
<i class="fa-solid fa-folder-tree menu-action-leading-icon"></i>
|
|
5299
|
+
<div class='display'>Workspaces</div>
|
|
5300
|
+
<div class='flexible'></div>
|
|
5301
|
+
</div>
|
|
5302
|
+
</a>
|
|
5293
5303
|
<% if (type === 'run') { %>
|
|
5294
5304
|
<button type='button' id='ask-ai-tab' class="btn header-item" data-static="ask-ai" data-workspace="<%=name%>" data-workspace-cwd="<%=path%>" data-ask-ai-trigger="true" data-tippy-content="Ask AI">
|
|
5295
5305
|
<div class='tab'>
|
|
@@ -5605,6 +5615,9 @@ header.navheader .mode-selector .community-mode-toggle {
|
|
|
5605
5615
|
<a class='btn mobile-sheet-action' href="/home" aria-label="Home" title="Home">
|
|
5606
5616
|
<i class="fa-solid fa-house"></i>
|
|
5607
5617
|
</a>
|
|
5618
|
+
<a class='btn mobile-sheet-action' href="/workspaces" aria-label="Workspaces" title="Workspaces">
|
|
5619
|
+
<i class="fa-solid fa-folder-tree"></i>
|
|
5620
|
+
</a>
|
|
5608
5621
|
<button type='button' class='btn mobile-sheet-action' data-mobile-proxy="#refresh-page" data-mobile-close-menu="true" aria-label="Refresh" title="Refresh">
|
|
5609
5622
|
<i class="fa-solid fa-rotate-right"></i>
|
|
5610
5623
|
</button>
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
</button>
|
|
28
28
|
</div>
|
|
29
29
|
<a href="/home" class="tab <%= sidebarSelected === 'home' ? 'selected' : '' %>" data-tippy-content="This machine"><i class='fas fa-laptop-code'></i><div class='caption'>This machine</div></a>
|
|
30
|
+
<a href="/workspaces" class="tab <%= sidebarSelected === 'workspaces' ? 'selected' : '' %>" data-tippy-content="Workspaces"><i class="fa-solid fa-folder-tree"></i><div class='caption'>Workspaces</div></a>
|
|
30
31
|
<a href="/network" class="tab <%= sidebarSelected === 'network' ? 'selected' : '' %>" data-tippy-content="Local network"><i class="fa-solid fa-wifi"></i><div class='caption'>Local network</div></a>
|
|
31
32
|
<% if (sidebarList.length > 0) { %>
|
|
32
33
|
<% sidebarList.forEach(({ host, name, platform }) => { %>
|