@things-factory/figure-service 10.1.111 → 10.1.112
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/dist-server/index.js +15 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/routers/figure-thumbnail-router.d.ts +12 -3
- package/dist-server/routers/figure-thumbnail-router.js +36 -30
- package/dist-server/routers/figure-thumbnail-router.js.map +1 -1
- package/dist-server/service/figure/figure-history.d.ts +1 -0
- package/dist-server/service/figure/figure-history.js +5 -0
- package/dist-server/service/figure/figure-history.js.map +1 -1
- package/dist-server/service/figure/figure-mutation.js +11 -8
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-replaced-draft.d.ts +1 -0
- package/dist-server/service/figure/figure-replaced-draft.js +5 -0
- package/dist-server/service/figure/figure-replaced-draft.js.map +1 -1
- package/dist-server/service/figure/figure-revert.js +9 -4
- package/dist-server/service/figure/figure-revert.js.map +1 -1
- package/dist-server/service/figure/figure-thumbnail-store.d.ts +35 -0
- package/dist-server/service/figure/figure-thumbnail-store.js +96 -0
- package/dist-server/service/figure/figure-thumbnail-store.js.map +1 -0
- package/dist-server/service/figure/figure-thumbnail-upgrade.d.ts +8 -0
- package/dist-server/service/figure/figure-thumbnail-upgrade.js +56 -0
- package/dist-server/service/figure/figure-thumbnail-upgrade.js.map +1 -0
- package/dist-server/service/figure/figure-write.d.ts +14 -10
- package/dist-server/service/figure/figure-write.js +9 -4
- package/dist-server/service/figure/figure-write.js.map +1 -1
- package/dist-server/service/figure/figure.d.ts +1 -0
- package/dist-server/service/figure/figure.js +5 -0
- package/dist-server/service/figure/figure.js.map +1 -1
- package/dist-server/service/figure/index.d.ts +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -2
- package/server/index.ts +15 -0
- package/server/routers/figure-thumbnail-router.ts +40 -36
- package/server/service/figure/figure-history.ts +9 -0
- package/server/service/figure/figure-mutation.ts +11 -9
- package/server/service/figure/figure-replaced-draft.ts +9 -0
- package/server/service/figure/figure-revert-db.test.ts +6 -5
- package/server/service/figure/figure-revert.ts +9 -4
- package/server/service/figure/figure-thumbnail-db.test.ts +218 -0
- package/server/service/figure/figure-thumbnail-store.ts +107 -0
- package/server/service/figure/figure-thumbnail-upgrade.pg.test.ts +93 -0
- package/server/service/figure/figure-thumbnail-upgrade.ts +70 -0
- package/server/service/figure/figure-write.ts +15 -4
- package/server/service/figure/figure.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-service",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.112",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"@hatiolab/figure-model": "^0.2.10",
|
|
29
29
|
"@operato/typeorm-history": "^10.0.0",
|
|
30
30
|
"@things-factory/ai-client-base": "^10.1.81",
|
|
31
|
+
"@things-factory/attachment-base": "^10.1.112",
|
|
31
32
|
"@things-factory/auth-base": "^10.1.111",
|
|
32
33
|
"@things-factory/env": "^10.1.20",
|
|
33
34
|
"@things-factory/shell": "^10.1.106",
|
|
@@ -35,5 +36,5 @@
|
|
|
35
36
|
"graphql-upload": "^15.0.2",
|
|
36
37
|
"koa-router": "^7.4.0"
|
|
37
38
|
},
|
|
38
|
-
"gitHead": "
|
|
39
|
+
"gitHead": "e300d1b60b98ccb80380afe8df6d2807b1bcff91"
|
|
39
40
|
}
|
package/server/index.ts
CHANGED
|
@@ -20,6 +20,21 @@ process.on('bootstrap-module-start' as any, () => {
|
|
|
20
20
|
console.log(`[figure-service:bootstrap] recipe templates ${templates.length}${refused.length ? `, left out: ${refused.join(' | ')}` : ''}`)
|
|
21
21
|
})
|
|
22
22
|
|
|
23
|
+
/*
|
|
24
|
+
* The pictures an install kept as base64 move into attachment-base by their content, once, at start (ADR-0104 decision
|
|
25
|
+
* 6). In the boot's one line: it writes, and sqlite holds one runner. A failure is said and the server goes on.
|
|
26
|
+
*/
|
|
27
|
+
import { getDataSource, inBootTurn } from '@things-factory/shell'
|
|
28
|
+
import { moveBase64Thumbnails } from './service/figure/figure-thumbnail-upgrade.js'
|
|
29
|
+
process.on('bootstrap-module-start' as any, () => {
|
|
30
|
+
inBootTurn(() => moveBase64Thumbnails(getDataSource()))
|
|
31
|
+
.then(moved => {
|
|
32
|
+
const any = moved.filter(m => m.moved || m.kept)
|
|
33
|
+
if (any.length) console.log(`[figure-service:bootstrap] thumbnails moved: ${any.map(m => `${m.table} ${m.moved}${m.kept ? ` (kept as base64: ${m.kept})` : ''}`).join(', ')}`)
|
|
34
|
+
})
|
|
35
|
+
.catch(error => console.error(`[figure-service] 썸네일을 옮기지 못했다 — ${error?.message ?? error}`))
|
|
36
|
+
})
|
|
37
|
+
|
|
23
38
|
export * from './service/index.js'
|
|
24
39
|
export * from './migrations/index.js'
|
|
25
40
|
export { registerFigureToolCategories } from './service/figure/figure-tools.js'
|
|
@@ -7,6 +7,7 @@ import { getRepository } from '@things-factory/shell'
|
|
|
7
7
|
import { readFileSync } from 'fs'
|
|
8
8
|
|
|
9
9
|
import { Figure } from '../service/figure/figure.js'
|
|
10
|
+
import { thumbnailAttachmentOf, thumbnailFiles, type ThumbnailFiles } from '../service/figure/figure-thumbnail-store.js'
|
|
10
11
|
import { recipeTemplates } from '../service/figure-recipe/figure-recipe-templates.js'
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -14,7 +15,7 @@ import { recipeTemplates } from '../service/figure-recipe/figure-recipe-template
|
|
|
14
15
|
*
|
|
15
16
|
* ## 왜 필요한가
|
|
16
17
|
*
|
|
17
|
-
* 썸네일은
|
|
18
|
+
* 썸네일은 attachment-base 가 내용 해시로 갖는다(ADR-0104 결정 6). 옛날에는 base64 문자열로 칸에 있었고, 그것을 목록 질의가
|
|
18
19
|
* 함께 실어 오면 카탈로그를 열 때마다 그림 전부가 JSON 안에 담겨 다시 온다 — data URL 은
|
|
19
20
|
* 브라우저가 캐시할 수 없다. [확인함] 표본 14개에 227KB, 한 장 최대 20KB.
|
|
20
21
|
*
|
|
@@ -29,21 +30,11 @@ import { recipeTemplates } from '../service/figure-recipe/figure-recipe-template
|
|
|
29
30
|
* `/thumbnail/:id` 로 두면 먼저 등록된 쪽이 이기고, 남의 표에서 id 를 찾아 404 를 낸다.
|
|
30
31
|
* 2. **행 전체를 읽는다.** board 는 `findOneBy` 로 행을 다 읽는다. figure 행에는 정본(`source`)이
|
|
31
32
|
* 붙어 있어, 그림 한 장에 정본까지 읽게 된다. 필요한 칸만 고른다.
|
|
32
|
-
* 3. **png 로 못 박는다.** figure 썸네일은 webp 다(모델러가 그렇게 만든다). data URL 이 스스로
|
|
33
|
-
*
|
|
33
|
+
* 3. **png 로 못 박는다.** figure 썸네일은 webp 다(모델러가 그렇게 만든다). 저장할 때 data URL 이 스스로
|
|
34
|
+
* 말한 형식을 attachment 의 mimetype 으로 쓴다 — 못 박으면 브라우저가 확장자와 다른 바이트를 받는다.
|
|
34
35
|
*/
|
|
35
36
|
export const figureThumbnailRouter = new Router()
|
|
36
37
|
|
|
37
|
-
/** `data:image/webp;base64,....` → 형식과 바이트. 아니면 undefined. */
|
|
38
|
-
function decodeDataUrl(dataUrl: string): { type: string; buffer: Buffer } | undefined {
|
|
39
|
-
const matched = /^data:([^;,]+);base64,(.*)$/s.exec(dataUrl)
|
|
40
|
-
if (!matched) {
|
|
41
|
-
return undefined
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return { type: matched[1], buffer: Buffer.from(matched[2], 'base64') }
|
|
45
|
-
}
|
|
46
|
-
|
|
47
38
|
figureThumbnailRouter.get('/figure-thumbnail/:id', async (context, next) => {
|
|
48
39
|
const { domain, user, unsafeIP, prohibitedPrivileges } = context.state
|
|
49
40
|
|
|
@@ -71,46 +62,59 @@ figureThumbnailRouter.get('/figure-thumbnail/:id', async (context, next) => {
|
|
|
71
62
|
return
|
|
72
63
|
}
|
|
73
64
|
|
|
74
|
-
|
|
65
|
+
await sendFigureThumbnail(context, next, context.params.id, domain)
|
|
66
|
+
})
|
|
75
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The picture of a figure the caller may read, by its hash (ADR-0104 decision 6). Exported for tests, which give
|
|
70
|
+
* their own `files`.
|
|
71
|
+
*/
|
|
72
|
+
export async function sendFigureThumbnail(
|
|
73
|
+
context: any,
|
|
74
|
+
next: () => Promise<any>,
|
|
75
|
+
id: string,
|
|
76
|
+
domain: { id: string; parentId?: string },
|
|
77
|
+
files: ThumbnailFiles = thumbnailFiles()
|
|
78
|
+
): Promise<void> {
|
|
76
79
|
/*
|
|
77
80
|
읽기는 부모 도메인까지 본다 — `figure` 질의와 같은 범위다. 물려받은 형상을 목록에는 보여 주고
|
|
78
81
|
그림만 404 를 내면, 화면은 「그림이 아직 없다」고 거짓말하게 된다.
|
|
79
82
|
*/
|
|
80
83
|
const found = await getRepository(Figure).findOne({
|
|
81
84
|
where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id },
|
|
82
|
-
|
|
85
|
+
relations: { domain: true },
|
|
86
|
+
select: { id: true, thumbnailHash: true, thumbnailUpdatedAt: true, domain: { id: true } }
|
|
83
87
|
})
|
|
84
|
-
|
|
85
|
-
const decoded = found?.thumbnail ? decodeDataUrl(found.thumbnail) : undefined
|
|
86
|
-
|
|
87
|
-
if (!decoded) {
|
|
88
|
+
if (!found?.thumbnailHash) {
|
|
88
89
|
context.status = 404
|
|
89
90
|
return
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
/*
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
경우를 위해 시각을 함께 실어 다시 물어볼 수 있게 한다.
|
|
94
|
+
그림은 **그 figure 의 도메인**에서 찾는다 — 묻는 도메인이 아니다(판정 조건 4). 해시가 같아도 다른 도메인의 행을
|
|
95
|
+
집지 않는다. 물려받은 figure 의 그림은 부모 도메인에 있다.
|
|
96
96
|
*/
|
|
97
|
-
const
|
|
97
|
+
const picture = await thumbnailAttachmentOf({ getRepository }, found.domain!.id, found.thumbnailHash)
|
|
98
|
+
if (!picture) {
|
|
99
|
+
context.status = 404
|
|
100
|
+
return
|
|
101
|
+
}
|
|
98
102
|
|
|
99
|
-
|
|
103
|
+
/*
|
|
104
|
+
**개인 캐시만 허락한다.** 이 그림은 권한을 본 뒤에 내주는 것이므로 공용 캐시(프록시)에 남으면 안 된다 —
|
|
105
|
+
attachment 의 공개 경로가 쓰는 `public` 을 따라 쓰지 않는다(판정 조건 1). 같은 내용은 같은 해시이므로 ETag 가
|
|
106
|
+
곧 내용이다. 부르는 쪽이 `?v=` 로 판을 가리므로 오래 두어도 낡은 그림이 남지 않는다.
|
|
107
|
+
*/
|
|
108
|
+
context.type = picture.mimetype
|
|
100
109
|
context.set('Cache-Control', 'private, max-age=31536000')
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (context.fresh) {
|
|
107
|
-
context.status = 304
|
|
108
|
-
return
|
|
109
|
-
}
|
|
110
|
+
context.set('ETag', `"${found.thumbnailHash}"`)
|
|
111
|
+
if (found.thumbnailUpdatedAt) context.set('Last-Modified', new Date(found.thumbnailUpdatedAt).toUTCString())
|
|
112
|
+
if (context.fresh) {
|
|
113
|
+
context.status = 304
|
|
114
|
+
return
|
|
110
115
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
})
|
|
116
|
+
await files.send(context, picture.path!, next)
|
|
117
|
+
}
|
|
114
118
|
|
|
115
119
|
/**
|
|
116
120
|
* A recipe template's thumbnail (ADR-0095 amended): the file drawn from its model, served behind the same door as the
|
|
@@ -80,6 +80,15 @@ export class FigureHistory implements HistoryEntityInterface<Figure> {
|
|
|
80
80
|
@Field({ nullable: true, description: 'The thumbnail as it looked at this version.' })
|
|
81
81
|
thumbnail?: string
|
|
82
82
|
|
|
83
|
+
/*
|
|
84
|
+
* 그림의 내용 해시(SHA-256). 그림 자체는 attachment-base 가 갖고(refType `figure-thumbnail`, refBy 이 해시),
|
|
85
|
+
* `/figure-thumbnail/:id` 가 권한을 본 뒤 내준다(ADR-0104 결정 6). `thumbnail` 칸은 옛 base64 가 남은 행을
|
|
86
|
+
* 기동 때 옮기기 위한 칸이다 — 옮긴 뒤에는 비어 있고, 새 그림은 그 칸에 쓰지 않는다.
|
|
87
|
+
*/
|
|
88
|
+
@Column({ nullable: true })
|
|
89
|
+
@Field({ nullable: true, description: 'SHA-256 of the thumbnail picture; the picture is served by /figure-thumbnail/:id.' })
|
|
90
|
+
thumbnailHash?: string
|
|
91
|
+
|
|
83
92
|
/*
|
|
84
93
|
* 그때의 비용이다. 오늘 다시 계산하면 오늘의 형식으로 나온다 — 그 판을 설명하지 못한다.
|
|
85
94
|
*/
|
|
@@ -65,7 +65,7 @@ export class FigureMutation {
|
|
|
65
65
|
if (!id) {
|
|
66
66
|
if (!creationId || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(creationId))
|
|
67
67
|
throw Error('새 모델 저장에는 생성 요청 식별자가 필요합니다.')
|
|
68
|
-
const prepared = { ...prepareFigureWrite(source), ...pictureOf(thumbnail) }
|
|
68
|
+
const prepared = { ...prepareFigureWrite(source), ...(await pictureOf(tx, domain, user, thumbnail)) }
|
|
69
69
|
const previous = await repository.findOne({ where: { id: creationId, domain: { id: domain.id } } })
|
|
70
70
|
if (previous) {
|
|
71
71
|
if (previous.source !== prepared.source) throw Error('이 생성 요청은 이미 다른 내용으로 저장되었습니다. 저장된 모델을 다시 열어 주세요.')
|
|
@@ -100,7 +100,7 @@ export class FigureMutation {
|
|
|
100
100
|
// Exact tenant ownership: an inherited catalog entry is not writable in the child domain.
|
|
101
101
|
const figure = await repository.findOne({ where: { id, domain: { id: domain.id } } })
|
|
102
102
|
if (!figure) throw Error('편집할 Figure를 찾을 수 없습니다.')
|
|
103
|
-
const prepared = { ...prepareFigureWrite(source, figure, expectedSource), ...pictureOf(thumbnail, figure) }
|
|
103
|
+
const prepared = { ...prepareFigureWrite(source, figure, expectedSource), ...(await pictureOf(tx, domain, user, thumbnail, figure)) }
|
|
104
104
|
// Compare-and-swap in the UPDATE itself: supported by SQLite as well as PostgreSQL.
|
|
105
105
|
// A pessimistic row lock is not supported by the product's SQLite deployment.
|
|
106
106
|
await compareAndSwapFigure(criteria => repository.update(criteria, { ...prepared, updater: user }),
|
|
@@ -128,12 +128,13 @@ export class FigureMutation {
|
|
|
128
128
|
// 형식을 어기면 여기서 던진다. 파생값은 서버가 계산한다.
|
|
129
129
|
const { source, derived, violations } = parseSource(figure.source)
|
|
130
130
|
|
|
131
|
+
const { thumbnail, ...rest } = figure
|
|
131
132
|
const saved = await repository.save({
|
|
132
|
-
...
|
|
133
|
+
...rest,
|
|
133
134
|
source,
|
|
134
135
|
...derived,
|
|
135
136
|
state: figure.state ?? 'draft',
|
|
136
|
-
|
|
137
|
+
...(await pictureOf(tx, domain, user, thumbnail)),
|
|
137
138
|
domain,
|
|
138
139
|
creator: user,
|
|
139
140
|
updater: user
|
|
@@ -184,6 +185,9 @@ export class FigureMutation {
|
|
|
184
185
|
행이 다른 이름을 말하고, 발행할 때 어느 쪽이 도면에 적히는지가 갈린다.
|
|
185
186
|
*/
|
|
186
187
|
if (patch.names !== undefined) assertNames(patch.names)
|
|
188
|
+
/* The picture is kept by its content; the patch's data URL is never written to the row. */
|
|
189
|
+
const { thumbnail: picturePatch, ...patchRest } = patch
|
|
190
|
+
const picture = await pictureOf(tx, domain, user, picturePatch, figure)
|
|
187
191
|
|
|
188
192
|
let renamed: string | undefined
|
|
189
193
|
if (patch.type !== undefined && patch.type !== figure.type) {
|
|
@@ -206,7 +210,7 @@ export class FigureMutation {
|
|
|
206
210
|
*/
|
|
207
211
|
domain,
|
|
208
212
|
...figure,
|
|
209
|
-
...
|
|
213
|
+
...patchRest,
|
|
210
214
|
/* 앞뒤 공백을 떼고 겹침까지 본 이름. `patch.type` 을 그대로 두면 다듬기 전 값이 들어간다. */
|
|
211
215
|
...(renamed !== undefined ? { type: renamed } : {}),
|
|
212
216
|
source,
|
|
@@ -220,10 +224,8 @@ export class FigureMutation {
|
|
|
220
224
|
state: 'draft',
|
|
221
225
|
...(derived ?? {}),
|
|
222
226
|
// 썸네일이 **썸네일로서** 바뀐 때만 시각을 갱신한다. 이름·설명 변경에는 안 건드린다.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
? new Date()
|
|
226
|
-
: figure.thumbnailUpdatedAt,
|
|
227
|
+
thumbnail: figure.thumbnail ?? null,
|
|
228
|
+
...picture,
|
|
227
229
|
updater: user
|
|
228
230
|
})
|
|
229
231
|
|
|
@@ -47,6 +47,15 @@ export class FigureReplacedDraft {
|
|
|
47
47
|
@Column({ nullable: true, ...longTextColumn() })
|
|
48
48
|
thumbnail?: string
|
|
49
49
|
|
|
50
|
+
/*
|
|
51
|
+
* 그림의 내용 해시(SHA-256). 그림 자체는 attachment-base 가 갖고(refType `figure-thumbnail`, refBy 이 해시),
|
|
52
|
+
* `/figure-thumbnail/:id` 가 권한을 본 뒤 내준다(ADR-0104 결정 6). `thumbnail` 칸은 옛 base64 가 남은 행을
|
|
53
|
+
* 기동 때 옮기기 위한 칸이다 — 옮긴 뒤에는 비어 있고, 새 그림은 그 칸에 쓰지 않는다.
|
|
54
|
+
*/
|
|
55
|
+
@Column({ nullable: true })
|
|
56
|
+
@Field({ nullable: true, description: 'SHA-256 of the thumbnail picture; the picture is served by /figure-thumbnail/:id.' })
|
|
57
|
+
thumbnailHash?: string
|
|
58
|
+
|
|
50
59
|
@Column({ nullable: true })
|
|
51
60
|
@Field({ nullable: true, description: 'The state the figure was in.' })
|
|
52
61
|
state?: string
|
|
@@ -60,8 +60,8 @@ beforeEach(async () => {
|
|
|
60
60
|
})
|
|
61
61
|
|
|
62
62
|
async function figureWithRelease() {
|
|
63
|
-
const figure = await dataSource!.getRepository(Figure).save({ type: 'REVERT_PROBE', name: 'probe', source: DRAFT, state: 'draft', version: 1,
|
|
64
|
-
await dataSource!.getRepository(FigureHistory).save({ originalId: figure.id, version: 1, type: 'REVERT_PROBE', name: 'probe', source: V1, state: 'released',
|
|
63
|
+
const figure = await dataSource!.getRepository(Figure).save({ type: 'REVERT_PROBE', name: 'probe', source: DRAFT, state: 'draft', version: 1, thumbnailHash: 'draft-picture', domain: DOMAIN })
|
|
64
|
+
await dataSource!.getRepository(FigureHistory).save({ originalId: figure.id, version: 1, type: 'REVERT_PROBE', name: 'probe', source: V1, state: 'released', thumbnailHash: 'v1-picture', domain: DOMAIN })
|
|
65
65
|
return figure
|
|
66
66
|
}
|
|
67
67
|
const uuid = () => require('crypto').randomUUID()
|
|
@@ -74,10 +74,11 @@ describe('a revert keeps the draft it writes over', () => {
|
|
|
74
74
|
const figure = await figureWithRelease()
|
|
75
75
|
const result = await inTx(tx => revertKeepingDraft(tx, DOMAIN, undefined, { id: figure.id, version: 1, requestId: uuid(), expectedSource: DRAFT }))
|
|
76
76
|
expect(JSON.parse((await row(figure.id)).source).name).toBe('Released one')
|
|
77
|
-
|
|
77
|
+
/* The picture goes by its hash (ADR-0104 decision 6): the release's own comes back with its source. */
|
|
78
|
+
expect((await row(figure.id)).thumbnailHash).toBe('v1-picture')
|
|
78
79
|
const [one] = await kept()
|
|
79
80
|
expect(one.source).toBe(DRAFT)
|
|
80
|
-
expect(one.
|
|
81
|
+
expect(one.thumbnailHash).toBe('draft-picture')
|
|
81
82
|
expect(one.revertedToVersion).toBe(1)
|
|
82
83
|
expect(result.kept.id).toBe(one.id)
|
|
83
84
|
/* Not a release: the released versions are as they were. */
|
|
@@ -133,7 +134,7 @@ describe('an undo brings the kept draft back only while nothing changed since',
|
|
|
133
134
|
const requestId = uuid()
|
|
134
135
|
await inTx(tx => undoRevert(tx, DOMAIN, undefined, { keptId: reverted.kept.id, requestId }))
|
|
135
136
|
expect((await row(figure.id)).source).toBe(DRAFT)
|
|
136
|
-
expect((await row(figure.id)).
|
|
137
|
+
expect((await row(figure.id)).thumbnailHash).toBe('draft-picture')
|
|
137
138
|
const records = await kept()
|
|
138
139
|
expect(records.map((k: any) => k.kind).sort()).toEqual(['revert', 'undo'])
|
|
139
140
|
expect(records.find((k: any) => k.kind === 'revert').restoredAt).toBeTruthy()
|
|
@@ -86,6 +86,7 @@ export async function revertKeepingDraft(
|
|
|
86
86
|
source: figure.source,
|
|
87
87
|
properties: figure.properties,
|
|
88
88
|
thumbnail: figure.thumbnail,
|
|
89
|
+
thumbnailHash: figure.thumbnailHash,
|
|
89
90
|
state: figure.state,
|
|
90
91
|
version: figure.version,
|
|
91
92
|
revertedToVersion: request.version,
|
|
@@ -101,8 +102,10 @@ export async function revertKeepingDraft(
|
|
|
101
102
|
source,
|
|
102
103
|
...derived,
|
|
103
104
|
properties: history.properties,
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
/* The picture goes by its hash (ADR-0104 decision 6); the release's own, as the source is. */
|
|
106
|
+
thumbnail: null,
|
|
107
|
+
thumbnailHash: history.thumbnailHash ?? null,
|
|
108
|
+
thumbnailUpdatedAt: history.thumbnailHash === figure.thumbnailHash ? figure.thumbnailUpdatedAt : new Date(),
|
|
106
109
|
state: 'draft',
|
|
107
110
|
...(user ? { updater: { id: user.id } } : {})
|
|
108
111
|
}
|
|
@@ -144,6 +147,7 @@ export async function undoRevert(
|
|
|
144
147
|
source: figure.source,
|
|
145
148
|
properties: figure.properties,
|
|
146
149
|
thumbnail: figure.thumbnail,
|
|
150
|
+
thumbnailHash: figure.thumbnailHash,
|
|
147
151
|
state: figure.state,
|
|
148
152
|
version: figure.version,
|
|
149
153
|
writtenSource: record.source,
|
|
@@ -158,8 +162,9 @@ export async function undoRevert(
|
|
|
158
162
|
source: record.source,
|
|
159
163
|
...derived,
|
|
160
164
|
properties: record.properties,
|
|
161
|
-
thumbnail:
|
|
162
|
-
|
|
165
|
+
thumbnail: null,
|
|
166
|
+
thumbnailHash: record.thumbnailHash ?? null,
|
|
167
|
+
thumbnailUpdatedAt: record.thumbnailHash === figure.thumbnailHash ? figure.thumbnailUpdatedAt : new Date(),
|
|
163
168
|
state: 'draft',
|
|
164
169
|
...(user ? { updater: { id: user.id } } : {})
|
|
165
170
|
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A figure's picture kept by attachment-base under its content hash (ADR-0104 decision 6; chief architect's ruling
|
|
3
|
+
* 2026-10-02): written once per content per domain, served only behind the figure permission from the figure's own
|
|
4
|
+
* domain, and the base64 an install already holds moved at start without losing one. On sqlite with the history
|
|
5
|
+
* subscriber on; the files go to a map instead of the configured storage.
|
|
6
|
+
*/
|
|
7
|
+
import type { DataSource } from 'typeorm'
|
|
8
|
+
|
|
9
|
+
const { DataSource: TypeOrmDataSource } = require('typeorm')
|
|
10
|
+
const shell = require('@things-factory/shell')
|
|
11
|
+
const authBase = require('@things-factory/auth-base')
|
|
12
|
+
const attachmentBase = require('@things-factory/attachment-base')
|
|
13
|
+
const { createFigureAsset, addFigureBox, serializeFigureAsset } = require('@hatiolab/figure-model')
|
|
14
|
+
|
|
15
|
+
let dataSource: DataSource | null = null
|
|
16
|
+
let Figure: any, FigureHistory: any, FigureReplacedDraft: any, Subscriber: any, FigureMutation: any
|
|
17
|
+
let keepThumbnail: any, sha256Of: any, useThumbnailFiles: any, moveBase64Thumbnails: any, sendFigureThumbnail: any
|
|
18
|
+
|
|
19
|
+
function loadCompiled(modulePath: string): any {
|
|
20
|
+
const { existsSync } = require('fs')
|
|
21
|
+
const { join } = require('path')
|
|
22
|
+
const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
|
|
23
|
+
if (!existsSync(resolved))
|
|
24
|
+
throw new Error(`figure-service 산출물이 없다: ${resolved}\n먼저 빌드할 것:\n yarn workspace @things-factory/figure-service build:server`)
|
|
25
|
+
return require(resolved)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Files in a map: put keeps the bytes under a new id, send hands them back. */
|
|
29
|
+
function mapFiles() {
|
|
30
|
+
const kept = new Map<string, Buffer>()
|
|
31
|
+
let n = 0
|
|
32
|
+
return {
|
|
33
|
+
kept,
|
|
34
|
+
async put(bytes: Buffer, _mimetype: string, name: string) {
|
|
35
|
+
const id = `00000000-0000-4000-8000-${String(++n).padStart(12, '0')}`
|
|
36
|
+
const path = `${id}.${name.split('.').pop()}`
|
|
37
|
+
kept.set(path, bytes)
|
|
38
|
+
return { id, path, size: bytes.length }
|
|
39
|
+
},
|
|
40
|
+
async send(koa: any, path: string) {
|
|
41
|
+
koa.body = kept.get(path)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const picture = (colour: number) => `data:image/webp;base64,${Buffer.from([0x52, 0x49, 0x46, 0x46, colour, colour, 1, 2]).toString('base64')}`
|
|
47
|
+
const bytesOf = (dataUrl: string) => Buffer.from(dataUrl.split(',')[1]!, 'base64')
|
|
48
|
+
const SOURCE = serializeFigureAsset(addFigureBox(createFigureAsset('Probe'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } }))
|
|
49
|
+
|
|
50
|
+
beforeAll(async () => {
|
|
51
|
+
Figure = loadCompiled('service/figure/figure').Figure
|
|
52
|
+
FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
|
|
53
|
+
FigureReplacedDraft = loadCompiled('service/figure/figure-replaced-draft').FigureReplacedDraft
|
|
54
|
+
Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
|
|
55
|
+
FigureMutation = loadCompiled('service/figure/figure-mutation').FigureMutation
|
|
56
|
+
;({ keepThumbnail, sha256Of, useThumbnailFiles } = loadCompiled('service/figure/figure-thumbnail-store'))
|
|
57
|
+
;({ moveBase64Thumbnails } = loadCompiled('service/figure/figure-thumbnail-upgrade'))
|
|
58
|
+
;({ sendFigureThumbnail } = loadCompiled('routers/figure-thumbnail-router'))
|
|
59
|
+
dataSource = new TypeOrmDataSource({
|
|
60
|
+
type: 'sqlite',
|
|
61
|
+
database: ':memory:',
|
|
62
|
+
synchronize: true,
|
|
63
|
+
dropSchema: true,
|
|
64
|
+
namingStrategy: new shell.NamingStrategy(),
|
|
65
|
+
entities: [...shell.entities, ...authBase.entities, ...attachmentBase.entities, Figure, FigureHistory, FigureReplacedDraft],
|
|
66
|
+
subscribers: [Subscriber],
|
|
67
|
+
logging: false
|
|
68
|
+
})
|
|
69
|
+
await dataSource.initialize()
|
|
70
|
+
shell.addDataSource('default', dataSource)
|
|
71
|
+
shell.addDataSource('tx', dataSource)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
afterAll(async () => {
|
|
75
|
+
shell.removeDataSource('default')
|
|
76
|
+
shell.removeDataSource('tx')
|
|
77
|
+
await dataSource?.destroy()
|
|
78
|
+
dataSource = null
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
let seq = 0
|
|
82
|
+
const newDomain = async (parent?: any) => {
|
|
83
|
+
const name = `thumb-${++seq}`
|
|
84
|
+
const saved = await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name, ...(parent ? { parent } : {}) })
|
|
85
|
+
return dataSource!.getRepository(shell.Domain).findOneOrFail({ where: { id: saved.id } })
|
|
86
|
+
}
|
|
87
|
+
const attachments = (domainId?: string) =>
|
|
88
|
+
dataSource!.getRepository(attachmentBase.Attachment).find({ where: { refType: 'figure-thumbnail', ...(domainId ? { domain: { id: domainId } } : {}) } })
|
|
89
|
+
function koa(ifNoneMatch?: string) {
|
|
90
|
+
const headers: Record<string, string> = {}
|
|
91
|
+
return {
|
|
92
|
+
status: 200,
|
|
93
|
+
type: '',
|
|
94
|
+
body: undefined as any,
|
|
95
|
+
headers,
|
|
96
|
+
set(k: string, v: string) {
|
|
97
|
+
headers[k] = v
|
|
98
|
+
},
|
|
99
|
+
get fresh() {
|
|
100
|
+
return !!ifNoneMatch && ifNoneMatch === headers['ETag']
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe('a picture kept by its content', () => {
|
|
106
|
+
it('the same picture twice in a domain is one attachment; in another domain, its own', async () => {
|
|
107
|
+
const files = mapFiles()
|
|
108
|
+
const a = await newDomain(),
|
|
109
|
+
b = await newDomain()
|
|
110
|
+
const tx = dataSource!.manager
|
|
111
|
+
const one = await keepThumbnail(tx, a, undefined, picture(7), files)
|
|
112
|
+
expect(await keepThumbnail(tx, a, undefined, picture(7), files)).toBe(one)
|
|
113
|
+
expect(one).toBe(sha256Of(bytesOf(picture(7))))
|
|
114
|
+
expect((await attachments(a.id)).length).toBe(1)
|
|
115
|
+
await keepThumbnail(tx, b, undefined, picture(7), files)
|
|
116
|
+
expect((await attachments(b.id)).length).toBe(1)
|
|
117
|
+
await expect(keepThumbnail(tx, a, undefined, 'not a picture', files)).rejects.toThrow(/data URL/)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('a save names the picture by its hash and never writes the bytes onto the row', async () => {
|
|
121
|
+
const was = useThumbnailFiles(mapFiles())
|
|
122
|
+
const domain = await newDomain()
|
|
123
|
+
const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
|
|
124
|
+
const figure = await dataSource!.getRepository(Figure).save({ type: 'SAVED', name: 'saved', source: SOURCE, state: 'draft', version: 0, domain })
|
|
125
|
+
await new FigureMutation().updateFigure(figure.id, { thumbnail: picture(9) }, context)
|
|
126
|
+
const row = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
|
|
127
|
+
expect([row.thumbnail, row.thumbnailHash]).toEqual([null, sha256Of(bytesOf(picture(9)))])
|
|
128
|
+
expect(row.thumbnailUpdatedAt).toBeTruthy()
|
|
129
|
+
useThumbnailFiles(was)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('served behind the permission, from the figure\'s own domain', () => {
|
|
134
|
+
it('an inherited figure\'s picture is the parent\'s, even when the child holds the same hash elsewhere', async () => {
|
|
135
|
+
const files = mapFiles()
|
|
136
|
+
const parent = await newDomain()
|
|
137
|
+
const child = await newDomain(parent)
|
|
138
|
+
const hash = await keepThumbnail(dataSource!.manager, parent, undefined, picture(3), files)
|
|
139
|
+
const figure = await dataSource!.getRepository(Figure).save({ type: 'INHERITED', name: 'x', source: SOURCE, state: 'released', version: 1, thumbnailHash: hash, thumbnailUpdatedAt: new Date(), domain: parent })
|
|
140
|
+
/* The child holds an attachment under the same hash, pointing at other bytes: it must not be picked. */
|
|
141
|
+
files.kept.set('child-copy.webp', Buffer.from('not the parent picture'))
|
|
142
|
+
await dataSource!.getRepository(attachmentBase.Attachment).save({ domain: child, name: 'x', mimetype: 'image/webp', encoding: 'binary', refType: 'figure-thumbnail', refBy: hash, category: 'image', size: '1', path: 'child-copy.webp' })
|
|
143
|
+
const ctx = koa()
|
|
144
|
+
await sendFigureThumbnail(ctx, async () => {}, figure.id, child, files)
|
|
145
|
+
expect(ctx.status).toBe(200)
|
|
146
|
+
expect(Buffer.compare(ctx.body, bytesOf(picture(3)))).toBe(0)
|
|
147
|
+
expect(ctx.headers['Cache-Control']).toBe('private, max-age=31536000')
|
|
148
|
+
expect(ctx.headers['ETag']).toBe(`"${hash}"`)
|
|
149
|
+
const again = koa(`"${hash}"`)
|
|
150
|
+
await sendFigureThumbnail(again, async () => {}, figure.id, child, files)
|
|
151
|
+
expect([again.status, again.body]).toEqual([304, undefined])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('a figure with no picture, or of a domain the caller cannot read, is 404', async () => {
|
|
155
|
+
const files = mapFiles()
|
|
156
|
+
const mine = await newDomain(),
|
|
157
|
+
other = await newDomain()
|
|
158
|
+
const bare = await dataSource!.getRepository(Figure).save({ type: 'BARE', name: 'x', source: SOURCE, state: 'draft', version: 0, domain: mine })
|
|
159
|
+
const ctx = koa()
|
|
160
|
+
await sendFigureThumbnail(ctx, async () => {}, bare.id, mine, files)
|
|
161
|
+
expect(ctx.status).toBe(404)
|
|
162
|
+
const hash = await keepThumbnail(dataSource!.manager, other, undefined, picture(5), files)
|
|
163
|
+
const theirs = await dataSource!.getRepository(Figure).save({ type: 'THEIRS', name: 'x', source: SOURCE, state: 'draft', version: 0, thumbnailHash: hash, domain: other })
|
|
164
|
+
const denied = koa()
|
|
165
|
+
await sendFigureThumbnail(denied, async () => {}, theirs.id, mine, files)
|
|
166
|
+
expect(denied.status).toBe(404)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
/*
|
|
171
|
+
* The upgrade, for every shape an install may hold: a figure with base64 only, its releases (with a domain, and an old
|
|
172
|
+
* one without), a draft a revert kept, a row with no picture, and a picture that is not a data URL. Run twice.
|
|
173
|
+
*/
|
|
174
|
+
describe('the base64 an install holds, moved at start without losing one', () => {
|
|
175
|
+
it('every shape moves once; what cannot move keeps its base64; a second start changes nothing', async () => {
|
|
176
|
+
const files = mapFiles()
|
|
177
|
+
const domain = await newDomain()
|
|
178
|
+
const figures = dataSource!.getRepository(Figure)
|
|
179
|
+
const histories = dataSource!.getRepository(FigureHistory)
|
|
180
|
+
/* Old rows are written as an install wrote them: straight to the table, no subscriber. */
|
|
181
|
+
const insert = (entity: any, values: any) => dataSource!.createQueryBuilder().insert().into(entity).values(values).callListeners(false).execute()
|
|
182
|
+
const figureId = '11111111-1111-4111-8111-111111111111'
|
|
183
|
+
await insert(Figure, { id: figureId, type: 'OLD', name: 'old', source: SOURCE, state: 'draft', version: 2, thumbnail: picture(1), domain: { id: domain.id } })
|
|
184
|
+
await insert(FigureHistory, { originalId: figureId, version: 1, type: 'OLD', state: 'released', thumbnail: picture(2), domain: { id: domain.id }, action: 'updated' })
|
|
185
|
+
await insert(FigureHistory, { originalId: figureId, version: 2, type: 'OLD', state: 'released', thumbnail: picture(1), action: 'updated' })
|
|
186
|
+
await insert(FigureReplacedDraft, { figureId, kind: 'revert', requestId: 'r-1', source: SOURCE, thumbnail: picture(4), state: 'draft', version: 2, writtenSource: SOURCE, writtenSourceHash: 'x', domain: { id: domain.id } })
|
|
187
|
+
await insert(Figure, { type: 'NONE', name: 'none', source: SOURCE, state: 'draft', version: 0, domain: { id: domain.id } })
|
|
188
|
+
await insert(Figure, { type: 'BROKEN', name: 'broken', source: SOURCE, state: 'draft', version: 0, thumbnail: 'garbage', domain: { id: domain.id } })
|
|
189
|
+
|
|
190
|
+
const first = await moveBase64Thumbnails(dataSource, files)
|
|
191
|
+
expect(first).toEqual([
|
|
192
|
+
{ table: 'figure', moved: 1, kept: 1 },
|
|
193
|
+
{ table: 'figure_history', moved: 2, kept: 0 },
|
|
194
|
+
{ table: 'figure_replaced_draft', moved: 1, kept: 0 }
|
|
195
|
+
])
|
|
196
|
+
const row = await figures.findOneOrFail({ where: { id: figureId } })
|
|
197
|
+
expect([row.thumbnail, row.thumbnailHash]).toEqual([null, sha256Of(bytesOf(picture(1)))])
|
|
198
|
+
const releases = await histories.find({ where: { originalId: figureId }, order: { version: 'ASC' } })
|
|
199
|
+
expect(releases.map((r: any) => [r.thumbnail, r.thumbnailHash])).toEqual([
|
|
200
|
+
[null, sha256Of(bytesOf(picture(2)))],
|
|
201
|
+
[null, sha256Of(bytesOf(picture(1)))]
|
|
202
|
+
])
|
|
203
|
+
const [kept] = await dataSource!.getRepository(FigureReplacedDraft).find()
|
|
204
|
+
expect([kept.thumbnail, kept.thumbnailHash]).toEqual([null, sha256Of(bytesOf(picture(4)))])
|
|
205
|
+
expect((await figures.findOneOrFail({ where: { type: 'BROKEN' } })).thumbnail).toBe('garbage')
|
|
206
|
+
expect((await figures.findOneOrFail({ where: { type: 'NONE' } })).thumbnailHash).toBeNull()
|
|
207
|
+
/* Three contents, three attachments: the figure and its second release share one. */
|
|
208
|
+
expect((await attachments(domain.id)).length).toBe(3)
|
|
209
|
+
|
|
210
|
+
const second = await moveBase64Thumbnails(dataSource, files)
|
|
211
|
+
expect(second).toEqual([
|
|
212
|
+
{ table: 'figure', moved: 0, kept: 1 },
|
|
213
|
+
{ table: 'figure_history', moved: 0, kept: 0 },
|
|
214
|
+
{ table: 'figure_replaced_draft', moved: 0, kept: 0 }
|
|
215
|
+
])
|
|
216
|
+
expect((await attachments(domain.id)).length).toBe(3)
|
|
217
|
+
})
|
|
218
|
+
})
|