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.
@@ -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, "&lt;")
23
+ .replace(/>/g, "&gt;")
24
+ .replace(/"/g, "&quot;")
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