pinokiod 7.2.7 → 7.2.9
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 +28 -0
- package/kernel/api/shell_run_template.js +273 -0
- package/kernel/index.js +2 -0
- package/kernel/shell.js +21 -2
- package/kernel/watch/context.js +42 -0
- package/kernel/watch/drivers/fs.js +71 -0
- package/kernel/watch/drivers/poll.js +33 -0
- package/kernel/watch/index.js +158 -0
- package/package.json +1 -1
- package/server/features/drafts/index.js +41 -0
- package/server/features/drafts/parser.js +169 -0
- package/server/features/drafts/public/drafts.js +1546 -0
- package/server/features/drafts/registry_import.js +427 -0
- package/server/features/drafts/routes.js +68 -0
- package/server/features/drafts/service.js +261 -0
- package/server/features/drafts/watcher.js +76 -0
- package/server/features/index.js +13 -0
- package/server/index.js +56 -7
- 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/routes/workspaces.js +44 -0
- package/server/socket.js +22 -11
- package/server/views/app.ejs +159 -1
- package/server/views/partials/main_sidebar.ejs +1 -0
- package/server/views/partials/workspace_row.ejs +61 -0
- package/server/views/terminal.ejs +8 -0
- package/server/views/terminals.ejs +1 -0
- package/server/views/workspaces.ejs +812 -0
|
@@ -0,0 +1,427 @@
|
|
|
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
|
+
const {
|
|
8
|
+
describeMediaRefs,
|
|
9
|
+
extractTitleAndBody,
|
|
10
|
+
normalizeTitle
|
|
11
|
+
} = require("./parser")
|
|
12
|
+
|
|
13
|
+
const DEFAULT_MAX_FILES = 10
|
|
14
|
+
const DEFAULT_MAX_FILE_BYTES = 25 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
const asyncHandler = (fn) => (req, res, next) => {
|
|
17
|
+
Promise.resolve(fn(req, res, next)).catch(next)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const escapeHtml = (value) => String(value || "")
|
|
21
|
+
.replace(/&/g, "&")
|
|
22
|
+
.replace(/</g, "<")
|
|
23
|
+
.replace(/>/g, ">")
|
|
24
|
+
.replace(/"/g, """)
|
|
25
|
+
|
|
26
|
+
function renderMessage(res, status, title, message) {
|
|
27
|
+
res.status(status).send(`<!doctype html>
|
|
28
|
+
<html>
|
|
29
|
+
<head>
|
|
30
|
+
<meta charset="utf-8">
|
|
31
|
+
<title>${escapeHtml(title)}</title>
|
|
32
|
+
<style>
|
|
33
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 40px; color: #111827; }
|
|
34
|
+
.box { max-width: 680px; border: 1px solid #d1d5db; border-radius: 8px; padding: 18px; }
|
|
35
|
+
h1 { margin: 0 0 8px; font-size: 22px; }
|
|
36
|
+
p { margin: 0; color: #4b5563; line-height: 1.5; }
|
|
37
|
+
</style>
|
|
38
|
+
</head>
|
|
39
|
+
<body>
|
|
40
|
+
<div class="box">
|
|
41
|
+
<h1>${escapeHtml(title)}</h1>
|
|
42
|
+
<p>${escapeHtml(message)}</p>
|
|
43
|
+
</div>
|
|
44
|
+
</body>
|
|
45
|
+
</html>`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderImportLauncher(res, { authorizeUrl, draftId, registryOrigin, autoOpen }) {
|
|
49
|
+
res.status(200).send(`<!doctype html>
|
|
50
|
+
<html>
|
|
51
|
+
<head>
|
|
52
|
+
<meta charset="utf-8">
|
|
53
|
+
<title>Import draft</title>
|
|
54
|
+
<style>
|
|
55
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 40px; color: #111827; background: #f8fafc; }
|
|
56
|
+
.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); }
|
|
57
|
+
h1 { margin: 0 0 8px; font-size: 22px; }
|
|
58
|
+
p { margin: 0 0 14px; color: #4b5563; line-height: 1.5; }
|
|
59
|
+
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; }
|
|
60
|
+
.muted { color: #6b7280; font-size: 13px; }
|
|
61
|
+
</style>
|
|
62
|
+
</head>
|
|
63
|
+
<body>
|
|
64
|
+
<div class="box">
|
|
65
|
+
<h1>Import draft</h1>
|
|
66
|
+
<p id="status">${autoOpen ? "Opening the registry authorization window..." : "Click Open registry to authorize the import."}</p>
|
|
67
|
+
<button id="open" type="button">Open registry</button>
|
|
68
|
+
<div class="muted" style="margin-top:12px;">Keep this Pinokio window open until the registry draft editor opens.</div>
|
|
69
|
+
</div>
|
|
70
|
+
<script>
|
|
71
|
+
window.__PINOKIO_DRAFT_IMPORT_VERSION = "metadata-b64";
|
|
72
|
+
const authorizeUrl = ${JSON.stringify(authorizeUrl)};
|
|
73
|
+
const draftId = ${JSON.stringify(draftId)};
|
|
74
|
+
const registryOrigin = ${JSON.stringify(registryOrigin)};
|
|
75
|
+
const autoOpen = ${JSON.stringify(Boolean(autoOpen))};
|
|
76
|
+
const statusEl = document.getElementById("status");
|
|
77
|
+
const openButton = document.getElementById("open");
|
|
78
|
+
let registryWindow = null;
|
|
79
|
+
|
|
80
|
+
function setStatus(message) {
|
|
81
|
+
statusEl.textContent = message;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function openRegistry() {
|
|
85
|
+
registryWindow = window.open(authorizeUrl, "_blank");
|
|
86
|
+
if (!registryWindow) {
|
|
87
|
+
setStatus("The registry window was blocked. Click Open registry to continue.");
|
|
88
|
+
} else {
|
|
89
|
+
setStatus("Authorize the import in the registry window.");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function completeImport(payload) {
|
|
94
|
+
setStatus("Uploading draft to the registry...");
|
|
95
|
+
const response = await fetch("/registry/draft-import/complete", {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: { "Content-Type": "application/json" },
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
draft: draftId,
|
|
100
|
+
token: payload.token,
|
|
101
|
+
registry: payload.registry,
|
|
102
|
+
app: payload.app || ""
|
|
103
|
+
})
|
|
104
|
+
});
|
|
105
|
+
const data = await response.json().catch(() => null);
|
|
106
|
+
if (!response.ok || !data || !data.editUrl) {
|
|
107
|
+
const detail = data && data.status ? " (" + data.status + ")" : "";
|
|
108
|
+
throw new Error(((data && data.error) || "Import failed.") + detail);
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
if (registryWindow && !registryWindow.closed) registryWindow.close();
|
|
112
|
+
} catch (_) {}
|
|
113
|
+
window.location.href = data.editUrl;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
window.addEventListener("message", (event) => {
|
|
117
|
+
if (event.origin !== registryOrigin) return;
|
|
118
|
+
const payload = event.data || {};
|
|
119
|
+
if (payload.type !== "pinokio:draft-import-token" || !payload.token || !payload.registry) return;
|
|
120
|
+
completeImport(payload).catch((error) => {
|
|
121
|
+
setStatus(error && error.message ? error.message : "Import failed.");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
openButton.addEventListener("click", openRegistry);
|
|
126
|
+
if (autoOpen) {
|
|
127
|
+
window.setTimeout(openRegistry, 100);
|
|
128
|
+
}
|
|
129
|
+
</script>
|
|
130
|
+
</body>
|
|
131
|
+
</html>`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeRegistryBase(raw, fallback) {
|
|
135
|
+
const value = String(raw || fallback || "").trim()
|
|
136
|
+
if (!value) return ""
|
|
137
|
+
try {
|
|
138
|
+
const url = new URL(value)
|
|
139
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return ""
|
|
140
|
+
url.hash = ""
|
|
141
|
+
url.search = ""
|
|
142
|
+
return url.toString().replace(/\/$/, "")
|
|
143
|
+
} catch (_) {
|
|
144
|
+
return ""
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function requestOrigin(req) {
|
|
149
|
+
const host = req.get("host") || "localhost:42000"
|
|
150
|
+
return `${req.protocol || "http"}://${host}`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function findDraftById(drafts, id) {
|
|
154
|
+
const normalized = String(id || "").trim()
|
|
155
|
+
if (!normalized) return null
|
|
156
|
+
const items = await drafts.listPending({})
|
|
157
|
+
return (items || []).find((item) => item && item.id === normalized) || null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isRegistryPostPublish(publish) {
|
|
161
|
+
if (!publish || typeof publish !== "object") return false
|
|
162
|
+
const target = String(publish.target || "").trim().toLowerCase()
|
|
163
|
+
const type = String(publish.type || "post").trim().toLowerCase()
|
|
164
|
+
return target === "registry" && type === "post"
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizeParent(parent) {
|
|
168
|
+
if (!parent || typeof parent !== "object" || Array.isArray(parent)) return null
|
|
169
|
+
const type = String(parent.type || "").trim().toLowerCase()
|
|
170
|
+
const url = String(parent.url || parent.repoUrl || "").trim()
|
|
171
|
+
if (type !== "app" || !url) return null
|
|
172
|
+
return { type: "app", url }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function buildDraftBundle(item, query = {}) {
|
|
176
|
+
const markdown = await fs.promises.readFile(item.postPath, "utf8")
|
|
177
|
+
const resultDir = path.dirname(item.postPath)
|
|
178
|
+
const titleFallback = item.title || (item.workspaceName ? `Draft for ${item.workspaceName}` : "Draft")
|
|
179
|
+
const extracted = extractTitleAndBody(markdown, titleFallback)
|
|
180
|
+
const metadataTitle = item.metadata && typeof item.metadata.title === "string"
|
|
181
|
+
? normalizeTitle(item.metadata.title)
|
|
182
|
+
: ""
|
|
183
|
+
const title = metadataTitle || extracted.title
|
|
184
|
+
const body = metadataTitle && extracted.title && normalizeTitle(extracted.title) === metadataTitle
|
|
185
|
+
? extracted.body
|
|
186
|
+
: (!metadataTitle ? extracted.body : String(markdown || "").trim())
|
|
187
|
+
const publish = item.publish && typeof item.publish === "object" ? item.publish : null
|
|
188
|
+
const media = await describeMediaRefs(markdown, resultDir, { mediaOnly: false })
|
|
189
|
+
return {
|
|
190
|
+
title,
|
|
191
|
+
body,
|
|
192
|
+
publish,
|
|
193
|
+
parent: normalizeParent(publish && publish.parent),
|
|
194
|
+
appSlug: String(query.app || "").trim(),
|
|
195
|
+
media
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function preflightBundle(bundle, options = {}) {
|
|
200
|
+
const maxFiles = Number(options.maxFiles || DEFAULT_MAX_FILES)
|
|
201
|
+
const maxFileBytes = Number(options.maxFileBytes || DEFAULT_MAX_FILE_BYTES)
|
|
202
|
+
if (!bundle.title) {
|
|
203
|
+
return "Draft title is missing."
|
|
204
|
+
}
|
|
205
|
+
if (!isRegistryPostPublish(bundle.publish)) {
|
|
206
|
+
return "This draft is not configured for registry publishing."
|
|
207
|
+
}
|
|
208
|
+
if (bundle.media.length > maxFiles) {
|
|
209
|
+
return `Draft has ${bundle.media.length} media files. The registry limit is ${maxFiles}.`
|
|
210
|
+
}
|
|
211
|
+
const missing = bundle.media.filter((item) => !item.exists)
|
|
212
|
+
if (missing.length > 0) {
|
|
213
|
+
return `Draft references missing media: ${missing.map((item) => item.ref).join(", ")}`
|
|
214
|
+
}
|
|
215
|
+
const oversized = bundle.media.find((item) => item.bytes > maxFileBytes)
|
|
216
|
+
if (oversized) {
|
|
217
|
+
return `Media file is too large: ${oversized.ref}. The per-file limit is ${Math.round(maxFileBytes / 1024 / 1024)} MB.`
|
|
218
|
+
}
|
|
219
|
+
return ""
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function uploadBundle(registryBase, token, bundle) {
|
|
223
|
+
const form = new FormData()
|
|
224
|
+
const metadata = JSON.stringify({
|
|
225
|
+
title: bundle.title,
|
|
226
|
+
body: bundle.body,
|
|
227
|
+
app: bundle.appSlug || "",
|
|
228
|
+
parent: bundle.parent || null,
|
|
229
|
+
media: bundle.media.map((item) => ({ path: item.ref }))
|
|
230
|
+
})
|
|
231
|
+
form.append("metadata_b64", Buffer.from(metadata, "utf8").toString("base64"))
|
|
232
|
+
for (const item of bundle.media) {
|
|
233
|
+
form.append("files", fs.createReadStream(item.path), {
|
|
234
|
+
filename: path.basename(item.ref),
|
|
235
|
+
contentType: mime.lookup(item.path) || "application/octet-stream",
|
|
236
|
+
knownLength: item.bytes
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
const endpoint = `${registryBase}/registry-bridge/draft-imports`
|
|
240
|
+
const headers = {
|
|
241
|
+
Authorization: `Bearer ${token}`,
|
|
242
|
+
...form.getHeaders()
|
|
243
|
+
}
|
|
244
|
+
const contentLength = await new Promise((resolve) => {
|
|
245
|
+
form.getLength((error, length) => resolve(error ? null : length))
|
|
246
|
+
})
|
|
247
|
+
if (Number.isFinite(contentLength)) {
|
|
248
|
+
headers["Content-Length"] = contentLength
|
|
249
|
+
}
|
|
250
|
+
console.log("[draft-import] request", {
|
|
251
|
+
endpoint,
|
|
252
|
+
media: bundle.media.length,
|
|
253
|
+
contentLength: Number.isFinite(contentLength) ? contentLength : null
|
|
254
|
+
})
|
|
255
|
+
const response = await axios.post(endpoint, form, {
|
|
256
|
+
timeout: 180000,
|
|
257
|
+
maxContentLength: Infinity,
|
|
258
|
+
maxBodyLength: Infinity,
|
|
259
|
+
validateStatus: () => true,
|
|
260
|
+
headers
|
|
261
|
+
})
|
|
262
|
+
if (response.status < 200 || response.status >= 300) {
|
|
263
|
+
const error = new Error(
|
|
264
|
+
response.data && response.data.error
|
|
265
|
+
? String(response.data.error)
|
|
266
|
+
: `Registry upload failed with status ${response.status}.`
|
|
267
|
+
)
|
|
268
|
+
error.status = response.status
|
|
269
|
+
error.registryEndpoint = endpoint
|
|
270
|
+
error.responseData = response.data
|
|
271
|
+
throw error
|
|
272
|
+
}
|
|
273
|
+
return response.data || {}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function uploadDraftFromRequest(drafts, query, token, registryBase, options = {}) {
|
|
277
|
+
const item = await findDraftById(drafts, query.draft)
|
|
278
|
+
if (!item) {
|
|
279
|
+
const error = new Error("The local draft is no longer available.")
|
|
280
|
+
error.status = 404
|
|
281
|
+
throw error
|
|
282
|
+
}
|
|
283
|
+
const bundle = await buildDraftBundle(item, query)
|
|
284
|
+
const problem = preflightBundle(bundle, options)
|
|
285
|
+
if (problem) {
|
|
286
|
+
const error = new Error(problem)
|
|
287
|
+
error.status = 400
|
|
288
|
+
throw error
|
|
289
|
+
}
|
|
290
|
+
return uploadBundle(registryBase, token, bundle)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function registerDraftImportRoutes(app, options = {}) {
|
|
294
|
+
const drafts = options.drafts
|
|
295
|
+
if (!drafts) {
|
|
296
|
+
throw new Error("drafts is required")
|
|
297
|
+
}
|
|
298
|
+
const defaultRegistryUrl = options.defaultRegistryUrl || "https://beta.pinokio.co"
|
|
299
|
+
const router = express.Router()
|
|
300
|
+
|
|
301
|
+
router.get("/registry/draft-import/authorize-url", asyncHandler(async (req, res) => {
|
|
302
|
+
const item = await findDraftById(drafts, req.query.draft)
|
|
303
|
+
if (!item) {
|
|
304
|
+
return res.status(404).json({ error: "The local draft is no longer available." })
|
|
305
|
+
}
|
|
306
|
+
const bundle = await buildDraftBundle(item, req.query)
|
|
307
|
+
const problem = preflightBundle(bundle, options)
|
|
308
|
+
if (problem) {
|
|
309
|
+
return res.status(400).json({ error: problem })
|
|
310
|
+
}
|
|
311
|
+
const registryBase = normalizeRegistryBase(req.query.registry, defaultRegistryUrl)
|
|
312
|
+
if (!registryBase) {
|
|
313
|
+
return res.status(400).json({ error: "The registry URL is invalid." })
|
|
314
|
+
}
|
|
315
|
+
const authorizeUrl = new URL("/draft-import/authorize", registryBase)
|
|
316
|
+
authorizeUrl.searchParams.set("handoff", "post_message")
|
|
317
|
+
authorizeUrl.searchParams.set("origin", requestOrigin(req))
|
|
318
|
+
authorizeUrl.searchParams.set("wait", "1")
|
|
319
|
+
if (bundle.appSlug) authorizeUrl.searchParams.set("app", bundle.appSlug)
|
|
320
|
+
res.setHeader("Cache-Control", "no-store")
|
|
321
|
+
return res.json({
|
|
322
|
+
draftId: item.id,
|
|
323
|
+
authorizeUrl: authorizeUrl.toString(),
|
|
324
|
+
registryOrigin: new URL(registryBase).origin
|
|
325
|
+
})
|
|
326
|
+
}))
|
|
327
|
+
|
|
328
|
+
router.get("/registry/draft-import/start", asyncHandler(async (req, res) => {
|
|
329
|
+
const item = await findDraftById(drafts, req.query.draft)
|
|
330
|
+
if (!item) {
|
|
331
|
+
return renderMessage(res, 404, "Draft not found", "The local draft is no longer available.")
|
|
332
|
+
}
|
|
333
|
+
const bundle = await buildDraftBundle(item, req.query)
|
|
334
|
+
const problem = preflightBundle(bundle, options)
|
|
335
|
+
if (problem) {
|
|
336
|
+
return renderMessage(res, 400, "Draft is not ready", problem)
|
|
337
|
+
}
|
|
338
|
+
const registryBase = normalizeRegistryBase(req.query.registry, defaultRegistryUrl)
|
|
339
|
+
if (!registryBase) {
|
|
340
|
+
return renderMessage(res, 400, "Registry unavailable", "The registry URL is invalid.")
|
|
341
|
+
}
|
|
342
|
+
const authorizeUrl = new URL("/draft-import/authorize", registryBase)
|
|
343
|
+
authorizeUrl.searchParams.set("handoff", "post_message")
|
|
344
|
+
authorizeUrl.searchParams.set("origin", requestOrigin(req))
|
|
345
|
+
if (bundle.appSlug) authorizeUrl.searchParams.set("app", bundle.appSlug)
|
|
346
|
+
res.setHeader("Cache-Control", "no-store")
|
|
347
|
+
res.setHeader("Cross-Origin-Opener-Policy", "unsafe-none")
|
|
348
|
+
return renderImportLauncher(res, {
|
|
349
|
+
authorizeUrl: authorizeUrl.toString(),
|
|
350
|
+
draftId: item.id,
|
|
351
|
+
registryOrigin: new URL(registryBase).origin,
|
|
352
|
+
autoOpen: req.query.auto === "1"
|
|
353
|
+
})
|
|
354
|
+
}))
|
|
355
|
+
|
|
356
|
+
router.post("/registry/draft-import/complete", asyncHandler(async (req, res) => {
|
|
357
|
+
const token = String(req.body && req.body.token || "").trim()
|
|
358
|
+
if (!token) {
|
|
359
|
+
return res.status(400).json({ error: "Missing registry token." })
|
|
360
|
+
}
|
|
361
|
+
const registryBase = normalizeRegistryBase(req.body && req.body.registry, defaultRegistryUrl)
|
|
362
|
+
if (!registryBase) {
|
|
363
|
+
return res.status(400).json({ error: "The registry URL is invalid." })
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
console.log("[draft-import] uploading", {
|
|
367
|
+
draft: req.body && req.body.draft,
|
|
368
|
+
registry: registryBase,
|
|
369
|
+
app: req.body && req.body.app ? String(req.body.app) : ""
|
|
370
|
+
})
|
|
371
|
+
const result = await uploadDraftFromRequest(
|
|
372
|
+
drafts,
|
|
373
|
+
{ draft: req.body && req.body.draft, app: req.body && req.body.app },
|
|
374
|
+
token,
|
|
375
|
+
registryBase,
|
|
376
|
+
options
|
|
377
|
+
)
|
|
378
|
+
if (result && result.editUrl) {
|
|
379
|
+
return res.json({ ok: true, editUrl: String(result.editUrl) })
|
|
380
|
+
}
|
|
381
|
+
return res.json({ ok: true, editUrl: registryBase })
|
|
382
|
+
} catch (error) {
|
|
383
|
+
const response = error && error.response
|
|
384
|
+
const status = response && response.status ? response.status : (error && error.status ? error.status : 500)
|
|
385
|
+
const endpoint = error && error.registryEndpoint ? error.registryEndpoint : `${registryBase}/registry-bridge/draft-imports`
|
|
386
|
+
const responseData = response && response.data ? response.data : (error && error.responseData ? error.responseData : null)
|
|
387
|
+
console.warn("[draft-import] upload failed", {
|
|
388
|
+
status,
|
|
389
|
+
endpoint,
|
|
390
|
+
error: error && error.message ? error.message : "Upload failed.",
|
|
391
|
+
response: typeof responseData === "string" ? responseData.slice(0, 500) : responseData
|
|
392
|
+
})
|
|
393
|
+
const message = response && response.data && response.data.error
|
|
394
|
+
? response.data.error
|
|
395
|
+
: (error && error.message ? error.message : "Upload failed.")
|
|
396
|
+
return res.status(status).json({ error: message, status, endpoint })
|
|
397
|
+
}
|
|
398
|
+
}))
|
|
399
|
+
|
|
400
|
+
router.get("/registry/draft-import/callback", asyncHandler(async (req, res) => {
|
|
401
|
+
const token = String(req.query.token || "").trim()
|
|
402
|
+
if (!token) {
|
|
403
|
+
return renderMessage(res, 400, "Missing token", "The registry did not return an import token.")
|
|
404
|
+
}
|
|
405
|
+
const registryBase = normalizeRegistryBase(req.query.registry, defaultRegistryUrl)
|
|
406
|
+
if (!registryBase) {
|
|
407
|
+
return renderMessage(res, 400, "Registry unavailable", "The registry URL is invalid.")
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
const result = await uploadDraftFromRequest(drafts, req.query, token, registryBase, options)
|
|
411
|
+
if (result && result.editUrl) {
|
|
412
|
+
return res.redirect(String(result.editUrl))
|
|
413
|
+
}
|
|
414
|
+
return renderMessage(res, 200, "Draft imported", "The registry accepted the draft.")
|
|
415
|
+
} catch (error) {
|
|
416
|
+
const response = error && error.response
|
|
417
|
+
const message = response && response.data && response.data.error
|
|
418
|
+
? response.data.error
|
|
419
|
+
: (error && error.message ? error.message : "Upload failed.")
|
|
420
|
+
return renderMessage(res, response && response.status ? response.status : 500, "Import failed", message)
|
|
421
|
+
}
|
|
422
|
+
}))
|
|
423
|
+
|
|
424
|
+
app.use(router)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
module.exports = registerDraftImportRoutes
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const express = require("express")
|
|
2
|
+
const path = require("path")
|
|
3
|
+
const registerDraftImportRoutes = require("./registry_import")
|
|
4
|
+
|
|
5
|
+
function asyncHandler(fn) {
|
|
6
|
+
return (req, res, next) => {
|
|
7
|
+
Promise.resolve(fn(req, res, next)).catch(next)
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function registerDraftRoutes(app, options = {}) {
|
|
12
|
+
const drafts = options.drafts
|
|
13
|
+
if (!drafts) {
|
|
14
|
+
throw new Error("drafts is required")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const router = express.Router()
|
|
18
|
+
router.get("/drafts", asyncHandler(async (req, res) => {
|
|
19
|
+
const cwd = typeof req.query.cwd === "string" ? req.query.cwd : ""
|
|
20
|
+
const items = await drafts.listPending({ cwd })
|
|
21
|
+
res.json({
|
|
22
|
+
ok: true,
|
|
23
|
+
items
|
|
24
|
+
})
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
router.post("/drafts/:id/dismiss", asyncHandler(async (req, res) => {
|
|
28
|
+
const ok = await drafts.dismiss(req.params.id, req.body && req.body.revision)
|
|
29
|
+
res.json({ ok })
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
router.get("/drafts/:id/media/:index", asyncHandler(async (req, res) => {
|
|
33
|
+
const item = typeof drafts.getPendingById === "function"
|
|
34
|
+
? await drafts.getPendingById(req.params.id)
|
|
35
|
+
: null
|
|
36
|
+
if (!item) {
|
|
37
|
+
res.status(404).send("Draft not found")
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
const index = Number(req.params.index)
|
|
41
|
+
const media = Array.isArray(item.media) && Number.isInteger(index)
|
|
42
|
+
? item.media[index]
|
|
43
|
+
: null
|
|
44
|
+
if (!media || !media.exists || !media.path) {
|
|
45
|
+
res.status(404).send("Media not found")
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
const filePath = path.resolve(media.path)
|
|
49
|
+
const basePath = path.resolve(item.resultDir)
|
|
50
|
+
const relative = path.relative(basePath, filePath)
|
|
51
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
52
|
+
res.status(403).send("Media path is outside the draft")
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
res.setHeader("Cache-Control", "no-store")
|
|
56
|
+
res.sendFile(filePath)
|
|
57
|
+
}))
|
|
58
|
+
|
|
59
|
+
router.get("/drafts.js", (req, res) => {
|
|
60
|
+
res.setHeader("Cache-Control", "no-store")
|
|
61
|
+
res.sendFile(path.resolve(__dirname, "public", "drafts.js"))
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
app.use(router)
|
|
65
|
+
registerDraftImportRoutes(app, options)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = registerDraftRoutes
|