dsh-design-studio 0.9.0 → 0.11.0
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.en.md +6 -4
- package/README.md +6 -4
- package/assets/demo.gif +0 -0
- package/assets/hero-sm.jpg +0 -0
- package/cordis.patch.yml +5 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/_shots/menu.jpg +0 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/app.css +64 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/cart.html +120 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/design.json +56 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/menu.html +200 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/order.html +135 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/tokens.css +43 -0
- package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/_shots/email.jpg +0 -0
- package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/design.json +20 -0
- package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/email.html +268 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/_shots/courses.jpg +0 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/courses.html +364 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/design.json +49 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/lesson.html +196 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/tokens.css +55 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/ui.css +255 -0
- package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/_shots/poster.jpg +0 -0
- package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/design.json +21 -0
- package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/poster.html +165 -0
- package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/tokens.css +35 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/_shots/home.jpg +0 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/app.css +155 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/booking.html +232 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/design.json +57 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/health.html +221 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/home.html +225 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/tokens.css +54 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/_shots/overview.jpg +0 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/app.css +79 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/design.json +51 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/entries.html +161 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/icons.svg +30 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/overview.html +181 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/tokens.css +50 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/_shots/home.jpg +0 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/book.html +279 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/design.json +21 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/home.html +348 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/style.css +297 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/tokens.css +49 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/01-cover.html +133 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/02-highlights.html +67 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/03-growth.html +89 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/04-milestones.html +92 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/05-next.html +99 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/06-thanks.html +61 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/_shots/01-cover.jpg +0 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/deck.css +67 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/design.json +25 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/tokens.css +30 -0
- package/gallery-seed/index.json +1 -0
- package/gallery-seed/thumbs/orderg-x__alley-noodles.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__autumn-sale.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__little-painter.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__moon-fair.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__paw-diary.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__pocket-ledger.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__time-bookshop.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__year-review.webp +0 -0
- package/lib/browser.js +23 -9
- package/lib/bundle.js +12 -3
- package/lib/client.js +3106 -111
- package/lib/core.js +44 -11
- package/lib/gallery/css.js +230 -0
- package/lib/gallery/html.js +313 -0
- package/lib/gallery/index.js +22 -0
- package/lib/gallery/market-routes.js +306 -0
- package/lib/gallery/market.js +623 -0
- package/lib/gallery/pack.js +311 -0
- package/lib/gallery/policy.js +1560 -0
- package/lib/gallery/qrfind.js +181 -0
- package/lib/gallery/render.js +148 -0
- package/lib/gallery/routes.js +360 -0
- package/lib/gallery/tar.js +118 -0
- package/lib/gallery/use.js +125 -0
- package/lib/gallery/zip.js +212 -0
- package/lib/handoff.js +291 -29
- package/lib/history.js +3 -1
- package/lib/index.js +118 -24
- package/lib/inspire.js +142 -0
- package/lib/offline.js +17 -0
- package/lib/picker.js +425 -32
- package/lib/serve.js +88 -11
- package/lib/works.js +238 -0
- package/locale/en.json +2 -1
- package/locale/offline/en.json +6 -0
- package/locale/offline/zh.json +6 -0
- package/locale/zh.json +2 -1
- package/package.json +10 -3
- package/skills/ui-design/SKILL.md +9 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设计库的接口(右侧栏 › 设计库):
|
|
3
|
+
*
|
|
4
|
+
* GET works 我的作品(顺手从设计台缓存补回以前的设计)
|
|
5
|
+
* POST works { op, dir, to? } pin / unpin / hide(移出列表)/ reveal(在文件夹里显示)/ relocate(挪走了,重新定位)
|
|
6
|
+
* GET workthumb ?id= 作品缩略图(第一屏,无头浏览器截的,按文件修改时间缓存)
|
|
7
|
+
* POST pack { dir, license } 打成 zip:先规范化,再用和机器人同一份规则查;过了存到「下载」
|
|
8
|
+
* POST usezip { session, name, data } 拖进来的 zip →「用这个开始」:查、落到新文件夹、登记、打开
|
|
9
|
+
* GET inspire 灵感(参考图、网址、我的风格)
|
|
10
|
+
* POST inspire { op, … } addImage / addUrl / addStyle / addMarket / remove
|
|
11
|
+
* GET inspimg ?id= 灵感里的图片
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import crypto from 'node:crypto'
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import os from 'node:os'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { execFile } from 'node:child_process'
|
|
19
|
+
import { listScreens } from '../core.js'
|
|
20
|
+
import { parseMeta, metaOfFile, KIND_SIZE, prepare } from '../look.js'
|
|
21
|
+
import { packDesign, zipOf, splitTitle } from './pack.js'
|
|
22
|
+
import { PERSONAL_CODES } from './policy.js'
|
|
23
|
+
import { readZip, ZipError } from './zip.js'
|
|
24
|
+
import { renderPack } from './render.js'
|
|
25
|
+
import { preparePack, installPack, baseFor, slugOf, UseError } from './use.js'
|
|
26
|
+
|
|
27
|
+
const THUMB_DIR = path.join(os.homedir(), '.dsh', 'cache', 'dsh-design-studio', 'works', 'thumbs')
|
|
28
|
+
const MAX_ZIP_BODY = 12 * 1024 * 1024 // base64 的 8MB zip
|
|
29
|
+
const MAX_IMG_BODY = 9 * 1024 * 1024
|
|
30
|
+
|
|
31
|
+
const idOf = (dir) => crypto.createHash('sha1').update(path.resolve(dir)).digest('hex').slice(0, 16)
|
|
32
|
+
|
|
33
|
+
/** 页面开头的 <title>(只读前 16KB) */
|
|
34
|
+
function headTitle(file) {
|
|
35
|
+
let fd
|
|
36
|
+
try {
|
|
37
|
+
fd = fs.openSync(file, 'r')
|
|
38
|
+
const buf = Buffer.alloc(16384)
|
|
39
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0)
|
|
40
|
+
const m = /<title[^>]*>([^<]{0,200})<\/title>/i.exec(buf.subarray(0, n).toString('utf8'))
|
|
41
|
+
return m ? m[1].replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/\s+/g, ' ').trim() : ''
|
|
42
|
+
} catch { return '' } finally { if (fd != null) try { fs.closeSync(fd) } catch { /* 关过了 */ } }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function localManifest(dir) {
|
|
46
|
+
try {
|
|
47
|
+
const m = JSON.parse(fs.readFileSync(path.join(dir, 'design.json'), 'utf8'))
|
|
48
|
+
return m && typeof m === 'object' && !Array.isArray(m) ? m : null
|
|
49
|
+
} catch { return null }
|
|
50
|
+
}
|
|
51
|
+
function manifestTitle(dir) {
|
|
52
|
+
const t = (localManifest(dir) || {}).title
|
|
53
|
+
return t && typeof t === 'object' ? String(t.zh || t.en || '').slice(0, 40) : ''
|
|
54
|
+
}
|
|
55
|
+
const TOKEN_RE = /^[0-9a-f]{32}$/
|
|
56
|
+
/** 分享记号 → 目录里的指纹(sha256 前 32 位)和给人看的短号 */
|
|
57
|
+
export const shareHashOf = (token) => crypto.createHash('sha256').update(String(token)).digest('hex').slice(0, 32)
|
|
58
|
+
function shareOf(dir) {
|
|
59
|
+
const tk = (localManifest(dir) || {}).shareToken
|
|
60
|
+
if (typeof tk !== 'string' || !TOKEN_RE.test(tk)) return null
|
|
61
|
+
const hash = shareHashOf(tk)
|
|
62
|
+
return { hash, ref: hash.slice(0, 12) }
|
|
63
|
+
}
|
|
64
|
+
/** 投稿表单的「短名」:标题或文件夹名能拼成 a-z0-9- 就预填,不然留空让机器人生成 */
|
|
65
|
+
const slugFor = (...xs) => { for (const x of xs) { const s = String(x || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40); if (s.length >= 3) return s } return '' }
|
|
66
|
+
|
|
67
|
+
/** 分享前体检:机器人会拒的那几类(被系统栏压住、图片坏了、跳到不存在的页…),名字和内容仓库的一样 */
|
|
68
|
+
const HARD_NAMES = {
|
|
69
|
+
covered: ['内容被系统栏压住', 'content under the system bar'],
|
|
70
|
+
capsule: ['被小程序胶囊挡住', 'covered by the mini-program capsule'],
|
|
71
|
+
island: ['被灵动岛挡住', 'covered by the Dynamic Island'],
|
|
72
|
+
winctl: ['被窗口按钮挡住', 'covered by the window buttons'],
|
|
73
|
+
homebar: ['被底部横条压住', 'covered by the home bar'],
|
|
74
|
+
img: ['图片坏了', 'broken image'],
|
|
75
|
+
deadlink: ['跳到不存在的页', 'link to a missing page'],
|
|
76
|
+
}
|
|
77
|
+
const hardMsg = (x, en) => {
|
|
78
|
+
const n = (HARD_NAMES[x.type] || [x.type, x.type])[en ? 1 : 0] + (x.text ? (en ? ` (${x.text})` : `(${x.text})`) : '')
|
|
79
|
+
return en ? `"${x.file}" has a serious layout problem: ${n}` : `「${x.file}」体检有严重问题:${n}`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 一条作品记录 → 右侧栏卡片要的样子(目录不在了 exists: false) */
|
|
83
|
+
export function describeWork(w) {
|
|
84
|
+
const dir = w.dir
|
|
85
|
+
let exists = false
|
|
86
|
+
try { exists = fs.statSync(dir).isDirectory() } catch { exists = false }
|
|
87
|
+
const base = { id: idOf(dir), dir, name: path.basename(dir), pinned: !!w.pinned, trust: w.trust || '', from: w.from || null, added: w.added || 0, exists, share: exists ? shareOf(dir) : null }
|
|
88
|
+
if (!exists) return { ...base, title: w.title || path.basename(dir), kind: w.kind || '', screens: w.screens || 0, updated: w.updated || w.seen || 0 }
|
|
89
|
+
const screens = listScreens(dir)
|
|
90
|
+
let updated = 0
|
|
91
|
+
let sig = ''
|
|
92
|
+
for (const f of screens) {
|
|
93
|
+
try {
|
|
94
|
+
const st = fs.statSync(path.join(dir, f))
|
|
95
|
+
updated = Math.max(updated, st.mtimeMs)
|
|
96
|
+
if (!sig) sig = `${f}:${Math.round(st.mtimeMs)}:${st.size}`
|
|
97
|
+
} catch { /* 刚删了 */ }
|
|
98
|
+
}
|
|
99
|
+
const first = screens[0] ? path.join(dir, screens[0]) : ''
|
|
100
|
+
const product = first ? splitTitle(headTitle(first)).product : ''
|
|
101
|
+
const kind = w.kind || (first ? parseMeta(metaOfFile(first)).kind : '') || 'web'
|
|
102
|
+
return { ...base, title: manifestTitle(dir) || product || w.title || path.basename(dir), kind, screens: screens.length, first: screens[0] || '', updated: Math.round(updated || w.updated || w.seen || 0), thumb: sig ? crypto.createHash('sha1').update(sig).digest('hex').slice(0, 10) : '' }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createLibraryRoutes({ admit, readBody, json, tracker, library, inspiration, studio, open, storeLoad, version = '', exported, log = () => {}, thumbDir = THUMB_DIR, downloads = path.join(os.homedir(), 'Downloads'), sessionCwd = async (id) => tracker.cwdOf(id), render = renderPack }) {
|
|
106
|
+
const lang = (body) => (body && body.lang === 'en' ? 'en' : 'zh')
|
|
107
|
+
|
|
108
|
+
// 作品库里登记的、目录在哪(id → 记录):只认列得出来的,别让请求指到别的目录
|
|
109
|
+
const byId = (id) => library.list().find((w) => idOf(w.dir) === id) || null
|
|
110
|
+
const byDir = (dir) => {
|
|
111
|
+
const w = library.get(String(dir || ''))
|
|
112
|
+
return w && !w.hidden ? w : null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function synced() {
|
|
116
|
+
try { library.sync(storeLoad()) } catch (e) { if (e.code !== 'ENOENT') log(e) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function works(req, res) {
|
|
120
|
+
if (!admit(req, res)) return
|
|
121
|
+
if (req.method === 'GET') {
|
|
122
|
+
synced()
|
|
123
|
+
const list = library.list().filter((w) => !w.hidden).map(describeWork)
|
|
124
|
+
list.sort((a, b) => (b.pinned - a.pinned) || (b.exists - a.exists) || (b.updated - a.updated))
|
|
125
|
+
return json(res, 200, { works: list.slice(0, 500) })
|
|
126
|
+
}
|
|
127
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
128
|
+
let body
|
|
129
|
+
try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
|
|
130
|
+
const w = byDir(body.dir)
|
|
131
|
+
if (!w) return json(res, 404, { error: 'not in library' })
|
|
132
|
+
switch (body.op) {
|
|
133
|
+
case 'pin':
|
|
134
|
+
case 'unpin':
|
|
135
|
+
library.put(w.dir, { pinned: body.op === 'pin' ? true : undefined })
|
|
136
|
+
break
|
|
137
|
+
case 'hide':
|
|
138
|
+
library.hide(w.dir)
|
|
139
|
+
break
|
|
140
|
+
case 'reveal': {
|
|
141
|
+
if (!fs.existsSync(w.dir)) return json(res, 404, { error: 'gone' })
|
|
142
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [w.dir]] : process.platform === 'win32' ? ['explorer', [w.dir]] : ['xdg-open', [w.dir]]
|
|
143
|
+
execFile(cmd, args, (e) => { if (e && process.platform !== 'win32') log(e) })
|
|
144
|
+
break
|
|
145
|
+
}
|
|
146
|
+
case 'relocate': {
|
|
147
|
+
const to = path.resolve(String(body.to || ''))
|
|
148
|
+
let ok = false
|
|
149
|
+
try { ok = fs.statSync(to).isDirectory() && listScreens(to).length > 0 } catch { ok = false }
|
|
150
|
+
if (!ok) return json(res, 400, { error: 'noScreens' })
|
|
151
|
+
library.move(w.dir, to)
|
|
152
|
+
break
|
|
153
|
+
}
|
|
154
|
+
default:
|
|
155
|
+
return json(res, 400, { error: 'bad op' })
|
|
156
|
+
}
|
|
157
|
+
json(res, 200, { ok: true })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 缩略图一次只截一张(无头浏览器同时开的页面有限,别挤掉体检)
|
|
161
|
+
let chain = Promise.resolve()
|
|
162
|
+
const making = new Map()
|
|
163
|
+
function makeThumb(w, d, file) {
|
|
164
|
+
if (making.has(file)) return making.get(file)
|
|
165
|
+
const job = chain.then(async () => {
|
|
166
|
+
const browser = studio.browser
|
|
167
|
+
if (!browser) throw new Error('no browser')
|
|
168
|
+
const { origin } = await open({ kind: 'file', root: w.dir, file: path.join(w.dir, d.first) })
|
|
169
|
+
const [vw, vh] = KIND_SIZE[d.kind] || KIND_SIZE.web
|
|
170
|
+
const scale = Math.min(1, 480 / vw)
|
|
171
|
+
const jpg = await browser.withPage({ url: `${origin}/${encodeURIComponent(d.first)}`, w: vw, h: Math.min(vh, Math.round(vw * 1.25)), scale, offline: library.strictRoot(w.dir) }, async (page) => {
|
|
172
|
+
await prepare(page, false)
|
|
173
|
+
return page.shot({ quality: 70 })
|
|
174
|
+
})
|
|
175
|
+
fs.mkdirSync(thumbDir, { recursive: true })
|
|
176
|
+
fs.writeFileSync(file + '.tmp', jpg)
|
|
177
|
+
fs.renameSync(file + '.tmp', file)
|
|
178
|
+
return file
|
|
179
|
+
})
|
|
180
|
+
chain = job.catch(() => {})
|
|
181
|
+
making.set(file, job)
|
|
182
|
+
job.finally(() => making.delete(file)).catch(() => {})
|
|
183
|
+
return job
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function workthumb(req, res) {
|
|
187
|
+
if (!admit(req, res)) return
|
|
188
|
+
const id = new URL(req.url, 'http://x').searchParams.get('id') || ''
|
|
189
|
+
const w = /^[0-9a-f]{16}$/.test(id) ? byId(id) : null
|
|
190
|
+
if (!w) return json(res, 404, { error: 'no such work' })
|
|
191
|
+
const d = describeWork(w)
|
|
192
|
+
if (!d.exists || !d.first || !d.thumb) return json(res, 404, { error: 'no screens' })
|
|
193
|
+
const file = path.join(thumbDir, `${d.id}-${d.thumb}.jpg`)
|
|
194
|
+
if (!fs.existsSync(file)) {
|
|
195
|
+
try {
|
|
196
|
+
await makeThumb(w, d, file)
|
|
197
|
+
// 旧版本的缩略图清掉
|
|
198
|
+
for (const f of fs.readdirSync(thumbDir)) if (f.startsWith(d.id + '-') && f !== path.basename(file)) fs.rmSync(path.join(thumbDir, f), { force: true })
|
|
199
|
+
} catch (e) {
|
|
200
|
+
log(e)
|
|
201
|
+
return json(res, 503, { error: 'thumb failed' })
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'private, max-age=86400' })
|
|
205
|
+
fs.createReadStream(file).on('error', () => res.end()).pipe(res)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function pack(req, res) {
|
|
209
|
+
if (!admit(req, res)) return
|
|
210
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
211
|
+
let body
|
|
212
|
+
try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
|
|
213
|
+
// 只打包作品库里的、或者某个会话正在预览的设计目录
|
|
214
|
+
const dir = path.resolve(String(body.dir || ''))
|
|
215
|
+
const snap = body.session ? tracker.snapshot(String(body.session)) : null
|
|
216
|
+
const open = snap && snap.target && snap.target.kind === 'file' && path.resolve(snap.target.root) === dir
|
|
217
|
+
if (!open && !byDir(dir)) return json(res, 404, { error: 'not a design here' })
|
|
218
|
+
const screens = listScreens(dir)
|
|
219
|
+
if (!screens.length) return json(res, 400, { error: 'noScreens' })
|
|
220
|
+
const local = localManifest(dir)
|
|
221
|
+
// 分享向导先要一份预填:名字、介绍、授权、分享过没有
|
|
222
|
+
if (body.info === true) {
|
|
223
|
+
const pick = (v) => (v && typeof v === 'object' ? String(v[lang(body)] || v.zh || v.en || '') : '')
|
|
224
|
+
return json(res, 200, { title: pick(local && local.title) || manifestTitle(dir) || path.basename(dir), description: pick(local && local.description), license: local && local.license === 'CC-BY-4.0' ? 'CC-BY-4.0' : 'CC0-1.0', share: shareOf(dir), screens: screens.length })
|
|
225
|
+
}
|
|
226
|
+
const license = body.license === 'CC-BY-4.0' ? 'CC-BY-4.0' : 'CC0-1.0'
|
|
227
|
+
// 分享到市场:四个确认都要勾;分享记号第一次分享时生成,存在作者自己的 design.json 里,包里只带它的指纹
|
|
228
|
+
const share = body.share === true
|
|
229
|
+
if (share && !['own', 'brand', 'private', 'rules'].every((k) => body.agree && body.agree[k] === true)) return json(res, 400, { error: 'agree' })
|
|
230
|
+
const token = share ? (local && typeof local.shareToken === 'string' && TOKEN_RE.test(local.shareToken) ? local.shareToken : crypto.randomBytes(16).toString('hex')) : ''
|
|
231
|
+
const shareHash = token ? shareHashOf(token) : ''
|
|
232
|
+
let p
|
|
233
|
+
try {
|
|
234
|
+
p = packDesign(dir, { screens, version, license, author: license === 'CC-BY-4.0' ? String(body.author || '') : '', mode: share ? 'share' : 'zip', lang: lang(body), title: share ? String(body.title || '') : '', description: share ? String(body.description || '') : '', shareHash, secrets: [token, local && local.shareToken].filter(Boolean) })
|
|
235
|
+
} catch (e) {
|
|
236
|
+
log(e)
|
|
237
|
+
return json(res, 500, { error: e.message })
|
|
238
|
+
}
|
|
239
|
+
// 分享前体检:断网开出来量一遍(同机器人那套),机器人会拒的先在这拦住;没装 Chrome 就跳过(机器人那边还会查)
|
|
240
|
+
const flags = [...p.flags]
|
|
241
|
+
if (share && p.ok) {
|
|
242
|
+
const browser = studio && studio.browser
|
|
243
|
+
if (!browser || typeof browser.available !== 'function' || !browser.available()) flags.push({ code: 'noRender', msg: lang(body) === 'en' ? 'Chrome not found, so the layout check was skipped; the review bot will run it after you submit' : '没找到 Chrome,没做分享前体检;投稿后机器人会查' })
|
|
244
|
+
else {
|
|
245
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ddp-share-'))
|
|
246
|
+
try {
|
|
247
|
+
for (const f of p.files) {
|
|
248
|
+
const to = path.join(tmp, f.path)
|
|
249
|
+
fs.mkdirSync(path.dirname(to), { recursive: true })
|
|
250
|
+
fs.writeFileSync(to, f.data)
|
|
251
|
+
}
|
|
252
|
+
const r = await render(tmp, { screens: p.manifest.screens.map((x) => x.file), kind: p.manifest.kind || 'web', shots: false, browser, qr: true })
|
|
253
|
+
// 像是有二维码:先提醒(插件不解码);真能扫出来的,机器人会退回
|
|
254
|
+
flags.unshift(...(r.qrLike || []).slice(0, 5).map((x) => ({ code: 'qr', x: x.file, msg: lang(body) === 'en' ? `"${x.file}" seems to contain a QR code: the gallery does not accept scannable QR codes, so replace it with a placeholder square (otherwise the review bot will send it back)` : `「${x.file}」里像是有二维码:市场不收能扫的二维码,换成一个占位方块(不然机器人会退回)` })))
|
|
255
|
+
for (const x of r.hard || []) p.errors.push({ code: 'hard', x: x.file, sev: 'error', msg: hardMsg(x, lang(body) === 'en') })
|
|
256
|
+
// 开不出来的屏:机器人那边也会拒(同一套体检)
|
|
257
|
+
for (const x of r.errors || []) p.errors.push({ code: 'render', x: x.file, sev: 'error', msg: lang(body) === 'en' ? `"${x.file}" could not be opened for the check (${String(x.error || '').slice(0, 80)}): open it on the canvas to see what is wrong, then try again` : `「${x.file}」没能打开量一遍(${String(x.error || '').slice(0, 80)}):先在画布上打开看看哪里不对,再试一次` })
|
|
258
|
+
} catch (e) {
|
|
259
|
+
log(e)
|
|
260
|
+
flags.push({ code: 'noRender', msg: lang(body) === 'en' ? 'The layout check could not run; the review bot will run it after you submit' : '分享前体检没跑成;投稿后机器人会查' })
|
|
261
|
+
} finally {
|
|
262
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
263
|
+
}
|
|
264
|
+
if (p.errors.length) p.ok = false
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
flags.sort((a, b) => Number(PERSONAL_CODES.has(b.code)) - Number(PERSONAL_CODES.has(a.code))) // 个人信息的提醒排最前面
|
|
268
|
+
const out = { ok: p.ok, errors: p.errors.slice(0, 60), more: Math.max(0, p.errors.length - 60), flags: flags.slice(0, 30), flagsMore: Math.max(0, flags.length - 30), notes: p.notes.slice(0, 60), skipped: p.skipped, bytes: p.bytes, title: p.manifest.title, screens: p.manifest.screens.length }
|
|
269
|
+
if (!p.ok) return json(res, 200, out)
|
|
270
|
+
try {
|
|
271
|
+
fs.mkdirSync(downloads, { recursive: true })
|
|
272
|
+
const t = p.manifest.title
|
|
273
|
+
const stem = slugOf(t.zh || t.en || path.basename(dir), 'design')
|
|
274
|
+
let file = path.join(downloads, `${stem}.zip`)
|
|
275
|
+
for (let i = 2; fs.existsSync(file) && i < 100; i++) file = path.join(downloads, `${stem}-${i}.zip`)
|
|
276
|
+
fs.writeFileSync(file, zipOf(p), { flag: 'wx' })
|
|
277
|
+
exported.add(file)
|
|
278
|
+
if (!share) return json(res, 200, { ...out, path: file })
|
|
279
|
+
// 作者这边记下:分享记号 + 这次填的名字介绍(下次分享预填、「我的作品」认得出已上架);从市场来的 from 留着
|
|
280
|
+
const keep = { ...p.manifest }
|
|
281
|
+
delete keep.shareHash
|
|
282
|
+
const mine = { ...keep, ...(local && local.from ? { from: local.from } : {}), shareToken: token }
|
|
283
|
+
const tmp = path.join(dir, `.design.json.${process.pid}.tmp`)
|
|
284
|
+
fs.writeFileSync(tmp, JSON.stringify(mine, null, 2) + '\n')
|
|
285
|
+
fs.renameSync(tmp, path.join(dir, 'design.json'))
|
|
286
|
+
json(res, 200, { ...out, path: file, share: { ref: shareHash.slice(0, 12), slug: slugFor(t.en, t.zh, path.basename(dir)), title: t.zh || t.en || '' } })
|
|
287
|
+
} catch (e) {
|
|
288
|
+
log(e)
|
|
289
|
+
json(res, 500, { error: e.message })
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function usezip(req, res) {
|
|
294
|
+
if (!admit(req, res)) return
|
|
295
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
296
|
+
let body
|
|
297
|
+
try { body = await readBody(req, MAX_ZIP_BODY) } catch (e) { return json(res, 400, { error: e.message === 'too large' ? 'big' : e.message }) }
|
|
298
|
+
const session = String(body.session || '')
|
|
299
|
+
if (!session) return json(res, 400, { error: '缺 session' })
|
|
300
|
+
const buf = Buffer.from(String(body.data || ''), 'base64')
|
|
301
|
+
let prepared
|
|
302
|
+
try {
|
|
303
|
+
const { files } = readZip(buf)
|
|
304
|
+
prepared = preparePack(files, { lang: lang(body) })
|
|
305
|
+
} catch (e) {
|
|
306
|
+
if (e instanceof ZipError) return json(res, 400, { error: 'zip', code: e.code, x: e.x })
|
|
307
|
+
if (e instanceof UseError) return json(res, 400, { error: e.message, errors: (e.errors || []).slice(0, 40), more: Math.max(0, (e.errors || []).length - 40) })
|
|
308
|
+
log(e)
|
|
309
|
+
return json(res, 500, { error: e.message })
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const name = String(body.name || '').replace(/\.zip$/i, '')
|
|
313
|
+
const t = prepared.manifest.title
|
|
314
|
+
const r = installPack(prepared, {
|
|
315
|
+
base: baseFor(await sessionCwd(session)),
|
|
316
|
+
name: (t && (t.zh || t.en)) || name,
|
|
317
|
+
trust: 'zip',
|
|
318
|
+
from: { zip: [...name].slice(0, 80).join('') || 'design.zip' },
|
|
319
|
+
library,
|
|
320
|
+
})
|
|
321
|
+
await tracker.setTarget(session, { kind: 'file', file: r.first, root: r.dir }, { source: 'user', popup: false, kind: prepared.manifest.kind, size: prepared.manifest.size || '' })
|
|
322
|
+
json(res, 200, { snap: tracker.snapshot(session), dir: r.dir, title: t, screens: r.screens, license: prepared.manifest.license, kind: prepared.manifest.kind, flags: prepared.flags.slice(0, 20) })
|
|
323
|
+
} catch (e) {
|
|
324
|
+
log(e)
|
|
325
|
+
json(res, 500, { error: e.message })
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function inspire(req, res) {
|
|
330
|
+
if (!admit(req, res)) return
|
|
331
|
+
if (req.method === 'GET') return json(res, 200, { items: inspiration.list() })
|
|
332
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
333
|
+
let body
|
|
334
|
+
try { body = await readBody(req, MAX_IMG_BODY) } catch (e) { return json(res, 400, { error: e.message }) }
|
|
335
|
+
try {
|
|
336
|
+
let item = null
|
|
337
|
+
if (body.op === 'addImage') item = inspiration.addImage({ name: body.name, data: Buffer.from(String(body.data || ''), 'base64') })
|
|
338
|
+
else if (body.op === 'addUrl') item = inspiration.addUrl({ url: body.url, name: body.name })
|
|
339
|
+
else if (body.op === 'addStyle') item = inspiration.addStyle({ name: body.name, style: body.style, mid: body.mid })
|
|
340
|
+
else if (body.op === 'addMarket') item = inspiration.addMarket({ mid: body.mid, mtype: body.mtype, name: body.name })
|
|
341
|
+
else if (body.op === 'remove') inspiration.remove(String(body.id || ''))
|
|
342
|
+
else return json(res, 400, { error: 'bad op' })
|
|
343
|
+
json(res, 200, { item, items: inspiration.list() })
|
|
344
|
+
} catch (e) {
|
|
345
|
+
json(res, 400, { error: e.message })
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function inspimg(req, res) {
|
|
350
|
+
if (!admit(req, res)) return
|
|
351
|
+
const id = new URL(req.url, 'http://x').searchParams.get('id') || ''
|
|
352
|
+
const file = /^[0-9a-f]{16}$/.test(id) ? inspiration.imageFile(id) : null
|
|
353
|
+
if (!file) return json(res, 404, { error: 'no such image' })
|
|
354
|
+
const ext = path.extname(file).slice(1)
|
|
355
|
+
res.writeHead(200, { 'Content-Type': ext === 'jpg' ? 'image/jpeg' : `image/${ext}`, 'Cache-Control': 'private, max-age=86400', 'X-Content-Type-Options': 'nosniff' })
|
|
356
|
+
fs.createReadStream(file).on('error', () => res.end()).pipe(res)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return { works, workthumb, pack, usezip, inspire, inspimg }
|
|
360
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 极简 tar 读取(npm 包的 .tgz:gzip + ustar),不加依赖。按不可信数据处理:
|
|
3
|
+
* - 只收 package/ 下的普通文件(目录项跳过);软链接、硬链接、设备文件、pax 全局头之外的怪类型一律拒
|
|
4
|
+
* - 路径:不能是绝对路径、不能有 ..、反斜杠、NUL、控制字符
|
|
5
|
+
* - 个数、单个大小、总大小有上限;解压按上限截断(声明的大小不可信)
|
|
6
|
+
* - 扩展名白名单在调用方(policy.js)再过一遍;.js 永远不写盘
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import zlib from 'node:zlib'
|
|
10
|
+
|
|
11
|
+
export const TAR_LIMITS = { files: 2000, file: 8 * 1024 * 1024, total: 100 * 1024 * 1024 }
|
|
12
|
+
|
|
13
|
+
class TarError extends Error {
|
|
14
|
+
constructor(code, x = '') {
|
|
15
|
+
super(`${code}${x ? ': ' + x : ''}`)
|
|
16
|
+
this.code = code
|
|
17
|
+
this.x = x
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function str(buf, start, len) {
|
|
22
|
+
const b = buf.subarray(start, start + len)
|
|
23
|
+
const nul = b.indexOf(0)
|
|
24
|
+
return (nul < 0 ? b : b.subarray(0, nul)).toString('utf8')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function octal(buf, start, len) {
|
|
28
|
+
const s = str(buf, start, len).trim()
|
|
29
|
+
if (!s) return 0
|
|
30
|
+
if (!/^[0-7]+$/.test(s)) throw new TarError('bad', 'size')
|
|
31
|
+
return parseInt(s, 8)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function safeName(raw) {
|
|
35
|
+
if (/[\x00-\x1f\x7f\\]/.test(raw)) return null
|
|
36
|
+
if (raw.startsWith('/') || /^[A-Za-z]:/.test(raw)) return null
|
|
37
|
+
const parts = raw.split('/').filter((p) => p !== '' && p !== '.')
|
|
38
|
+
if (!parts.length || parts.some((p) => p === '..')) return null
|
|
39
|
+
return parts.join('/')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* pax 扩展头里的 path(长文件名);别的键不认
|
|
44
|
+
* 记录是「<字节数> key=value\n」,长度按字节算(中文一个字 3 字节),所以按 Buffer 偏移切,不能按字符串下标
|
|
45
|
+
* 记录边界不对(长度越界、不以换行结尾、没有空格)就整个头作废:抛 bad
|
|
46
|
+
*/
|
|
47
|
+
function paxPath(data) {
|
|
48
|
+
let i = 0
|
|
49
|
+
let out = ''
|
|
50
|
+
while (i < data.length) {
|
|
51
|
+
if (data[i] === 0) break // 尾部补零
|
|
52
|
+
const sp = data.indexOf(0x20, i)
|
|
53
|
+
if (sp < 0 || sp - i > 20) throw new TarError('bad', 'pax')
|
|
54
|
+
const lenStr = data.toString('latin1', i, sp)
|
|
55
|
+
if (!/^[1-9][0-9]*$/.test(lenStr)) throw new TarError('bad', 'pax')
|
|
56
|
+
const n = Number(lenStr)
|
|
57
|
+
const end = i + n
|
|
58
|
+
if (end > data.length || end <= sp + 1 || data[end - 1] !== 0x0a) throw new TarError('bad', 'pax')
|
|
59
|
+
const rec = data.subarray(sp + 1, end - 1)
|
|
60
|
+
const eq = rec.indexOf(0x3d)
|
|
61
|
+
if (eq > 0 && rec.toString('utf8', 0, eq) === 'path') out = rec.toString('utf8', eq + 1)
|
|
62
|
+
i = end
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* .tgz(或已解压的 .tar)→ [{ path, data }],path 已去掉 package/ 前缀
|
|
69
|
+
* 拒:TarError code = bad / big / count / link / path
|
|
70
|
+
*/
|
|
71
|
+
export function readTar(input, limits = TAR_LIMITS) {
|
|
72
|
+
let buf = input
|
|
73
|
+
if (buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b) {
|
|
74
|
+
try { buf = zlib.gunzipSync(buf, { maxOutputLength: limits.total + limits.files * 1024 + 1024 * 1024 }) } catch { throw new TarError('big') }
|
|
75
|
+
}
|
|
76
|
+
const files = []
|
|
77
|
+
const seen = new Set()
|
|
78
|
+
let total = 0
|
|
79
|
+
let pending = '' // pax 头给下一项的名字
|
|
80
|
+
let off = 0
|
|
81
|
+
while (off + 512 <= buf.length) {
|
|
82
|
+
const h = buf.subarray(off, off + 512)
|
|
83
|
+
if (h.every((b) => b === 0)) break
|
|
84
|
+
// 校验和:头里 148–155 当空格算
|
|
85
|
+
let sum = 0
|
|
86
|
+
for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 32 : h[i]
|
|
87
|
+
if (sum !== octal(h, 148, 8)) throw new TarError('bad', 'checksum')
|
|
88
|
+
const type = String.fromCharCode(h[156] || 48)
|
|
89
|
+
const size = octal(h, 124, 12)
|
|
90
|
+
const prefix = str(h, 345, 155)
|
|
91
|
+
const name = pending || (prefix ? `${prefix}/${str(h, 0, 100)}` : str(h, 0, 100))
|
|
92
|
+
pending = ''
|
|
93
|
+
const start = off + 512
|
|
94
|
+
const end = start + size
|
|
95
|
+
if (end > buf.length) throw new TarError('bad', 'truncated')
|
|
96
|
+
off = start + Math.ceil(size / 512) * 512
|
|
97
|
+
if (type === 'x') { pending = paxPath(buf.subarray(start, end)); continue }
|
|
98
|
+
if (type === 'g') continue
|
|
99
|
+
if (type === '5') continue // 目录
|
|
100
|
+
if (type === '1' || type === '2') throw new TarError('link', name)
|
|
101
|
+
if (type !== '0' && type !== '\0' && type !== '7') throw new TarError('link', name) // 设备、管道、GNU 长名之类
|
|
102
|
+
const clean = safeName(name)
|
|
103
|
+
if (!clean || !clean.startsWith('package/')) throw new TarError('path', name)
|
|
104
|
+
const rel = clean.slice('package/'.length)
|
|
105
|
+
if (!rel) continue
|
|
106
|
+
if (size > limits.file) throw new TarError('big', rel)
|
|
107
|
+
total += size
|
|
108
|
+
if (total > limits.total) throw new TarError('big')
|
|
109
|
+
const key = rel.toLowerCase()
|
|
110
|
+
if (seen.has(key)) throw new TarError('path', rel)
|
|
111
|
+
seen.add(key)
|
|
112
|
+
files.push({ path: rel, data: Buffer.from(buf.subarray(start, end)) })
|
|
113
|
+
if (files.length > limits.files) throw new TarError('count')
|
|
114
|
+
}
|
|
115
|
+
return files
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export { TarError }
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「用这个开始」:把一个设计包(朋友传的 zip,以后还有市场下载的)落到用户电脑上的一个新文件夹里
|
|
3
|
+
*
|
|
4
|
+
* 1. 规范化(去注释、站外链接改成 #)后,用和机器人同一份规则再查一遍;不过就拒
|
|
5
|
+
* 2. 定目标目录:<会话目录>/<名字>,重名加 -2;会话目录是家目录或没有时放 ~/DSH 设计/<名字>
|
|
6
|
+
* 3. 新建目录(必须是新的),防软链接越界,按清单里屏的顺序逐个写(画布按创建时间排,清单也认)
|
|
7
|
+
* 4. design.json 带上来源(from),CC-BY 的写 ATTRIBUTION.md
|
|
8
|
+
* 5. 在作品库登记:trust(zip / market)+ 每个 HTML 落地时的 sha256 —— 预览时内容没变就按「别人做的」加严格规矩
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { checkPack, normalizeHtml, message, cleanPath } from './policy.js'
|
|
15
|
+
import { sha256 } from '../works.js'
|
|
16
|
+
|
|
17
|
+
/** 文件夹名:去掉路径和系统不让用的字符,限长;空了用 fallback */
|
|
18
|
+
export function slugOf(name, fallback = 'template') {
|
|
19
|
+
const s = [...String(name || '').normalize('NFC')
|
|
20
|
+
.replace(/\.zip$/i, '')
|
|
21
|
+
.replace(/[\x00-\x1f\x7f/\\:*?"<>|]+/g, ' ')
|
|
22
|
+
.replace(/^[.\s_-]+|[.\s]+$/g, '')
|
|
23
|
+
.replace(/\s+/g, '-')].slice(0, 40).join('').replace(/[.-]+$/, '')
|
|
24
|
+
return s && !/^(con|prn|aux|nul|com\d|lpt\d)$/i.test(s) ? s : fallback
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 目标目录:base/<slug>,有了就 -2、-3… */
|
|
28
|
+
export function freshDir(base, slug) {
|
|
29
|
+
for (let i = 1; i < 100; i++) {
|
|
30
|
+
const p = path.join(base, i === 1 ? slug : `${slug}-${i}`)
|
|
31
|
+
if (!fs.existsSync(p)) return p
|
|
32
|
+
}
|
|
33
|
+
throw new Error('同名的文件夹太多了')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 放在哪个目录下:会话目录(不是家目录、存在)→ 它;不然 ~/DSH 设计 */
|
|
37
|
+
export function baseFor(cwd, home = os.homedir()) {
|
|
38
|
+
try {
|
|
39
|
+
if (cwd && path.resolve(cwd) !== path.resolve(home) && fs.statSync(cwd).isDirectory()) return path.resolve(cwd)
|
|
40
|
+
} catch { /* 用默认 */ }
|
|
41
|
+
return path.join(home, 'DSH 设计')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class UseError extends Error {
|
|
45
|
+
constructor(msg, errors = []) {
|
|
46
|
+
super(msg)
|
|
47
|
+
this.errors = errors
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 包([{ path, data: Buffer }])→ 检查过的、要写的文件 + 清单;不过关抛 UseError(errors 里是每条问题)
|
|
53
|
+
* 规范化只做「去掉 / 改掉」的事(注释、站外链接、表单地址),别的照原样
|
|
54
|
+
*/
|
|
55
|
+
export function preparePack(files, { lang = 'zh' } = {}) {
|
|
56
|
+
const fixed = files.map((f) => {
|
|
57
|
+
if (/\.html$/i.test(f.path) && Buffer.isBuffer(f.data)) {
|
|
58
|
+
const r = normalizeHtml(f.data.toString('utf8'), { file: f.path })
|
|
59
|
+
return { path: f.path, data: Buffer.from(r.text, 'utf8') }
|
|
60
|
+
}
|
|
61
|
+
return f
|
|
62
|
+
})
|
|
63
|
+
const r = checkPack(fixed, { mode: 'install' })
|
|
64
|
+
if (!r.ok) {
|
|
65
|
+
const errors = r.errors.map((p) => ({ ...p, msg: message(p, lang) }))
|
|
66
|
+
throw new UseError(errors.some((e) => e.code === 'noManifest') ? 'noManifest' : 'check', errors)
|
|
67
|
+
}
|
|
68
|
+
if (r.type !== 'template') throw new UseError('type')
|
|
69
|
+
return { files: fixed, manifest: r.manifest, screens: r.screens, kind: r.kind, flags: r.flags.map((p) => ({ ...p, msg: message(p, lang) })) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 落地。from:{ zip: 文件名 } 或 { id, rev, author, license, url }(市场)
|
|
74
|
+
* 返回 { dir, first, screens, manifest }
|
|
75
|
+
*/
|
|
76
|
+
export function installPack(prepared, { base, name, trust, from, library, now = Date.now() }) {
|
|
77
|
+
const { files, manifest, screens } = prepared
|
|
78
|
+
fs.mkdirSync(base, { recursive: true })
|
|
79
|
+
const realBase = fs.realpathSync(base)
|
|
80
|
+
const dest = freshDir(realBase, slugOf(name))
|
|
81
|
+
fs.mkdirSync(dest)
|
|
82
|
+
const realDest = fs.realpathSync(dest)
|
|
83
|
+
if (path.dirname(realDest) !== realBase) throw new UseError('path')
|
|
84
|
+
const inside = (p) => p.startsWith(realDest + path.sep)
|
|
85
|
+
const rank = new Map(screens.map((s, i) => [s.toLowerCase(), i]))
|
|
86
|
+
const ordered = [...files].filter((f) => f.path !== 'design.json').sort((a, b) => {
|
|
87
|
+
const ra = rank.has(a.path.toLowerCase()) ? rank.get(a.path.toLowerCase()) : -1
|
|
88
|
+
const rb = rank.has(b.path.toLowerCase()) ? rank.get(b.path.toLowerCase()) : -1
|
|
89
|
+
// 样式、图片先写(屏一出来就有样子),屏按清单顺序最后写
|
|
90
|
+
if ((ra < 0) !== (rb < 0)) return ra < 0 ? -1 : 1
|
|
91
|
+
return ra - rb || a.path.localeCompare(b.path)
|
|
92
|
+
})
|
|
93
|
+
const hashes = {}
|
|
94
|
+
for (const f of ordered) {
|
|
95
|
+
const rel = cleanPath(f.path)
|
|
96
|
+
if (!rel) throw new UseError('path')
|
|
97
|
+
const abs = path.resolve(realDest, rel)
|
|
98
|
+
if (!inside(abs)) throw new UseError('path')
|
|
99
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
100
|
+
const parent = fs.realpathSync(path.dirname(abs))
|
|
101
|
+
if (parent !== realDest && !inside(parent)) throw new UseError('path')
|
|
102
|
+
fs.writeFileSync(abs, f.data, { flag: 'wx' }) // 新目录里不该已经有:有就是包里重名,出错
|
|
103
|
+
if (/\.html$/i.test(rel)) hashes[rel] = sha256(f.data)
|
|
104
|
+
}
|
|
105
|
+
const m = { ...manifest, from: { ...from, at: new Date(now).toISOString() } }
|
|
106
|
+
delete m.shareToken
|
|
107
|
+
delete m.shareHash // 别人分享时的记号,不是你的
|
|
108
|
+
fs.writeFileSync(path.join(realDest, 'design.json'), JSON.stringify(m, null, 2) + '\n', { flag: 'wx' })
|
|
109
|
+
if (manifest.license === 'CC-BY-4.0') {
|
|
110
|
+
const who = (manifest.author && (manifest.author.name || manifest.author.login)) || (from && from.author) || ''
|
|
111
|
+
const title = (manifest.title && (manifest.title.zh || manifest.title.en)) || ''
|
|
112
|
+
// CC BY 要写清:名字、作者、来源、授权(来源只认 GitHub 上的链接:目录签过名,这里再挡一下)
|
|
113
|
+
const src = from && typeof from.url === 'string' && /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/[\w./%-]*$/.test(from.url) ? from.url : ''
|
|
114
|
+
const line = `这套设计基于「${title}」${who ? `(作者:${who})` : ''},按 CC BY 4.0 授权使用。${src ? `来源:${src}` : ''}\nBased on "${title}"${who ? ` by ${who}` : ''}, licensed under CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/).${src ? ` Source: ${src}` : ''}\n`
|
|
115
|
+
// 包里已经有署名(转手的 CC BY 设计):原来的留着,这一手接在后面
|
|
116
|
+
const had = ordered.find((f) => f.path.toLowerCase() === 'attribution.md')
|
|
117
|
+
if (had) fs.appendFileSync(path.join(realDest, cleanPath(had.path)), '\n' + line)
|
|
118
|
+
else fs.writeFileSync(path.join(realDest, 'ATTRIBUTION.md'), `# 署名 / Attribution\n\n${line}`, { flag: 'wx' })
|
|
119
|
+
}
|
|
120
|
+
const title = (manifest.title && (manifest.title.zh || manifest.title.en)) || path.basename(realDest)
|
|
121
|
+
if (library) library.put(realDest, { title, kind: manifest.kind, screens: screens.length, trust, hashes, from: m.from, hidden: undefined })
|
|
122
|
+
return { dir: realDest, first: path.join(realDest, screens[0]), screens, manifest: m }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export { UseError }
|