@things-factory/figure-service 10.1.112 → 10.1.113
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.d.ts +1 -0
- package/dist-server/index.js +2 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/routers/figure-thumbnail-router.js +12 -5
- package/dist-server/routers/figure-thumbnail-router.js.map +1 -1
- package/dist-server/service/figure/figure-thumbnail-reader.d.ts +7 -0
- package/dist-server/service/figure/figure-thumbnail-reader.js +21 -0
- package/dist-server/service/figure/figure-thumbnail-reader.js.map +1 -0
- package/dist-server/service/figure/figure-thumbnail-store.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/server/index.ts +2 -0
- package/server/routers/figure-thumbnail-router.ts +11 -4
- package/server/service/figure/figure-thumbnail-db.test.ts +20 -2
- package/server/service/figure/figure-thumbnail-reader.ts +20 -0
- package/server/service/figure/figure-thumbnail-store.ts +1 -0
package/dist-server/index.d.ts
CHANGED
package/dist-server/index.js
CHANGED
|
@@ -27,6 +27,8 @@ process.on('bootstrap-module-start', () => {
|
|
|
27
27
|
*/
|
|
28
28
|
const shell_1 = require("@things-factory/shell");
|
|
29
29
|
const figure_thumbnail_upgrade_js_1 = require("./service/figure/figure-thumbnail-upgrade.js");
|
|
30
|
+
/* Who may read a figure picture by its attachment path (ADR-0106): registered with attachment-base at load. */
|
|
31
|
+
require("./service/figure/figure-thumbnail-reader.js");
|
|
30
32
|
process.on('bootstrap-module-start', () => {
|
|
31
33
|
(0, shell_1.inBootTurn)(() => (0, figure_thumbnail_upgrade_js_1.moveBase64Thumbnails)((0, shell_1.getDataSource)()))
|
|
32
34
|
.then(moved => {
|
package/dist-server/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../server/index.ts"],"names":[],"mappings":";;;;AAAA,uBAAoB;AAEpB;;;;;;GAMG;AACH,sEAA+E;AAC/E,IAAA,8CAA4B,GAAE,CAAA;AAE9B;;;GAGG;AACH,mGAAoF;AACpF,OAAO,CAAC,EAAE,CAAC,wBAA+B,EAAE,GAAG,EAAE;IAC/C,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,GAAG,IAAA,4CAAe,GAAE,CAAA;IAChD,OAAO,CAAC,GAAG,CAAC,+CAA+C,SAAS,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,eAAe,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;AAC7I,CAAC,CAAC,CAAA;AAEF;;;GAGG;AACH,iDAAiE;AACjE,8FAAmF;AACnF,OAAO,CAAC,EAAE,CAAC,wBAA+B,EAAE,GAAG,EAAE;IAC/C,IAAA,kBAAU,EAAC,GAAG,EAAE,CAAC,IAAA,kDAAoB,EAAC,IAAA,qBAAa,GAAE,CAAC,CAAC;SACpD,IAAI,CAAC,KAAK,CAAC,EAAE;QACZ,MAAM,GAAG,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,IAAI,CAAC,CAAA;QAChD,IAAI,GAAG,CAAC,MAAM;YAAE,OAAO,CAAC,GAAG,CAAC,gDAAgD,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,qBAAqB,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IAChL,CAAC,CAAC;SACD,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,mCAAmC,KAAK,EAAE,OAAO,IAAI,KAAK,EAAE,CAAC,CAAC,CAAA;AAChG,CAAC,CAAC,CAAA;AAEF,6DAAkC;AAClC,gEAAqC;AACrC,oEAA+E;AAAtE,+HAAA,4BAA4B,OAAA","sourcesContent":["import './routes.js'\n\n/*\n * 저작 도구를 어시스턴트 레지스트리에 등록한다.\n *\n * 이 패키지가 적재되면 대화면(도크)이 저작 중인 Figure 의 정본·비용·발행 판정을 읽고 후보를\n * 요청할 수 있다. 능력을 복제하지 않고 figure-service 가 이미 가진 진입점을 투영한다 —\n * 노출 범위는 대화면이 `toolCategories` 로 고른다(twin-ai · board-import 와 같은 방식).\n */\nimport { registerFigureToolCategories } from './service/figure/figure-tools.js'\nregisterFigureToolCategories()\n\n/*\n * The recipe templates are read once at start (ADR-0095, amended): what was served, and any file whose form is wrong,\n * said here rather than found by a person as a missing template.\n */\nimport { recipeTemplates } from './service/figure-recipe/figure-recipe-templates.js'\nprocess.on('bootstrap-module-start' as any, () => {\n const { templates, refused } = recipeTemplates()\n console.log(`[figure-service:bootstrap] recipe templates ${templates.length}${refused.length ? `, left out: ${refused.join(' | ')}` : ''}`)\n})\n\n/*\n * The pictures an install kept as base64 move into attachment-base by their content, once, at start (ADR-0104 decision\n * 6). In the boot's one line: it writes, and sqlite holds one runner. A failure is said and the server goes on.\n */\nimport { getDataSource, inBootTurn } from '@things-factory/shell'\nimport { moveBase64Thumbnails } from './service/figure/figure-thumbnail-upgrade.js'\nprocess.on('bootstrap-module-start' as any, () => {\n inBootTurn(() => moveBase64Thumbnails(getDataSource()))\n .then(moved => {\n const any = moved.filter(m => m.moved || m.kept)\n 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(', ')}`)\n })\n .catch(error => console.error(`[figure-service] 썸네일을 옮기지 못했다 — ${error?.message ?? error}`))\n})\n\nexport * from './service/index.js'\nexport * from './migrations/index.js'\nexport { registerFigureToolCategories } from './service/figure/figure-tools.js'\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../server/index.ts"],"names":[],"mappings":";;;;AAAA,uBAAoB;AAEpB;;;;;;GAMG;AACH,sEAA+E;AAC/E,IAAA,8CAA4B,GAAE,CAAA;AAE9B;;;GAGG;AACH,mGAAoF;AACpF,OAAO,CAAC,EAAE,CAAC,wBAA+B,EAAE,GAAG,EAAE;IAC/C,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,GAAG,IAAA,4CAAe,GAAE,CAAA;IAChD,OAAO,CAAC,GAAG,CAAC,+CAA+C,SAAS,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,eAAe,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;AAC7I,CAAC,CAAC,CAAA;AAEF;;;GAGG;AACH,iDAAiE;AACjE,8FAAmF;AACnF,+GAA+G;AAC/G,uDAAoD;AACpD,OAAO,CAAC,EAAE,CAAC,wBAA+B,EAAE,GAAG,EAAE;IAC/C,IAAA,kBAAU,EAAC,GAAG,EAAE,CAAC,IAAA,kDAAoB,EAAC,IAAA,qBAAa,GAAE,CAAC,CAAC;SACpD,IAAI,CAAC,KAAK,CAAC,EAAE;QACZ,MAAM,GAAG,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,IAAI,CAAC,CAAA;QAChD,IAAI,GAAG,CAAC,MAAM;YAAE,OAAO,CAAC,GAAG,CAAC,gDAAgD,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,qBAAqB,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IAChL,CAAC,CAAC;SACD,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,mCAAmC,KAAK,EAAE,OAAO,IAAI,KAAK,EAAE,CAAC,CAAC,CAAA;AAChG,CAAC,CAAC,CAAA;AAEF,6DAAkC;AAClC,gEAAqC;AACrC,oEAA+E;AAAtE,+HAAA,4BAA4B,OAAA","sourcesContent":["import './routes.js'\n\n/*\n * 저작 도구를 어시스턴트 레지스트리에 등록한다.\n *\n * 이 패키지가 적재되면 대화면(도크)이 저작 중인 Figure 의 정본·비용·발행 판정을 읽고 후보를\n * 요청할 수 있다. 능력을 복제하지 않고 figure-service 가 이미 가진 진입점을 투영한다 —\n * 노출 범위는 대화면이 `toolCategories` 로 고른다(twin-ai · board-import 와 같은 방식).\n */\nimport { registerFigureToolCategories } from './service/figure/figure-tools.js'\nregisterFigureToolCategories()\n\n/*\n * The recipe templates are read once at start (ADR-0095, amended): what was served, and any file whose form is wrong,\n * said here rather than found by a person as a missing template.\n */\nimport { recipeTemplates } from './service/figure-recipe/figure-recipe-templates.js'\nprocess.on('bootstrap-module-start' as any, () => {\n const { templates, refused } = recipeTemplates()\n console.log(`[figure-service:bootstrap] recipe templates ${templates.length}${refused.length ? `, left out: ${refused.join(' | ')}` : ''}`)\n})\n\n/*\n * The pictures an install kept as base64 move into attachment-base by their content, once, at start (ADR-0104 decision\n * 6). In the boot's one line: it writes, and sqlite holds one runner. A failure is said and the server goes on.\n */\nimport { getDataSource, inBootTurn } from '@things-factory/shell'\nimport { moveBase64Thumbnails } from './service/figure/figure-thumbnail-upgrade.js'\n/* Who may read a figure picture by its attachment path (ADR-0106): registered with attachment-base at load. */\nimport './service/figure/figure-thumbnail-reader.js'\nprocess.on('bootstrap-module-start' as any, () => {\n inBootTurn(() => moveBase64Thumbnails(getDataSource()))\n .then(moved => {\n const any = moved.filter(m => m.moved || m.kept)\n 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(', ')}`)\n })\n .catch(error => console.error(`[figure-service] 썸네일을 옮기지 못했다 — ${error?.message ?? error}`))\n})\n\nexport * from './service/index.js'\nexport * from './migrations/index.js'\nexport { registerFigureToolCategories } from './service/figure/figure-tools.js'\n"]}
|
|
@@ -87,16 +87,23 @@ async function sendFigureThumbnail(context, next, id, domain, files = (0, figure
|
|
|
87
87
|
attachment 의 공개 경로가 쓰는 `public` 을 따라 쓰지 않는다(판정 조건 1). 같은 내용은 같은 해시이므로 ETag 가
|
|
88
88
|
곧 내용이다. 부르는 쪽이 `?v=` 로 판을 가리므로 오래 두어도 낡은 그림이 남지 않는다.
|
|
89
89
|
*/
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
90
|
+
const ours = () => {
|
|
91
|
+
context.type = picture.mimetype;
|
|
92
|
+
context.set('Cache-Control', 'private, max-age=31536000');
|
|
93
|
+
context.set('ETag', `"${found.thumbnailHash}"`);
|
|
94
|
+
if (found.thumbnailUpdatedAt)
|
|
95
|
+
context.set('Last-Modified', new Date(found.thumbnailUpdatedAt).toUTCString());
|
|
96
|
+
};
|
|
97
|
+
ours();
|
|
98
|
+
/* koa answers `fresh` only for a 2xx: before a body the status is still 404, and the check never matched. */
|
|
99
|
+
context.status = 200;
|
|
95
100
|
if (context.fresh) {
|
|
96
101
|
context.status = 304;
|
|
97
102
|
return;
|
|
98
103
|
}
|
|
99
104
|
await files.send(context, picture.path, next);
|
|
105
|
+
/* The storage sets its own (S3 its ETag, attachment's Cache-Control): this door's are said last. */
|
|
106
|
+
ours();
|
|
100
107
|
}
|
|
101
108
|
/**
|
|
102
109
|
* A recipe template's thumbnail (ADR-0095 amended): the file drawn from its model, served behind the same door as the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"figure-thumbnail-router.js","sourceRoot":"","sources":["../../server/routers/figure-thumbnail-router.ts"],"names":[],"mappings":";;;AAuEA,
|
|
1
|
+
{"version":3,"file":"figure-thumbnail-router.js","sourceRoot":"","sources":["../../server/routers/figure-thumbnail-router.ts"],"names":[],"mappings":"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","sourcesContent":["import Router from 'koa-router'\nimport { In } from 'typeorm'\n\nimport { checkPermission } from '@things-factory/auth-base'\nimport { getRepository } from '@things-factory/shell'\n\nimport { readFileSync } from 'fs'\n\nimport { Figure } from '../service/figure/figure.js'\nimport { thumbnailAttachmentOf, thumbnailFiles, type ThumbnailFiles } from '../service/figure/figure-thumbnail-store.js'\nimport { recipeTemplates } from '../service/figure-recipe/figure-recipe-templates.js'\n\n/**\n * 썸네일을 **그림으로** 내보내는 길.\n *\n * ## 왜 필요한가\n *\n * 썸네일은 attachment-base 가 내용 해시로 갖는다(ADR-0104 결정 6). 옛날에는 base64 문자열로 칸에 있었고, 그것을 목록 질의가\n * 함께 실어 오면 카탈로그를 열 때마다 그림 전부가 JSON 안에 담겨 다시 온다 — data URL 은\n * 브라우저가 캐시할 수 없다. [확인함] 표본 14개에 227KB, 한 장 최대 20KB.\n *\n * 그림으로 내보내면 브라우저가 캐시하고, 부르는 쪽이 `?v=thumbnailUpdatedAt` 을 붙이므로 그림이\n * 다시 만들어진 것만 다시 받는다. 목록 질의에서는 `thumbnail` 을 아예 고르지 않는다.\n *\n * ## board 를 베끼면서 고친 것 셋\n *\n * `standalone-board-service-router` 의 `/thumbnail/:id` 가 본이지만 그대로 두면 안 되는 것들이 있다.\n *\n * 1. **길 이름이 겹친다.** 이 앱은 board 도 함께 싣는다(메뉴가 board-ui 를 끌어온다).\n * `/thumbnail/:id` 로 두면 먼저 등록된 쪽이 이기고, 남의 표에서 id 를 찾아 404 를 낸다.\n * 2. **행 전체를 읽는다.** board 는 `findOneBy` 로 행을 다 읽는다. figure 행에는 정본(`source`)이\n * 붙어 있어, 그림 한 장에 정본까지 읽게 된다. 필요한 칸만 고른다.\n * 3. **png 로 못 박는다.** figure 썸네일은 webp 다(모델러가 그렇게 만든다). 저장할 때 data URL 이 스스로\n * 말한 형식을 attachment 의 mimetype 으로 쓴다 — 못 박으면 브라우저가 확장자와 다른 바이트를 받는다.\n */\nexport const figureThumbnailRouter = new Router()\n\nfigureThumbnailRouter.get('/figure-thumbnail/:id', async (context, next) => {\n const { domain, user, unsafeIP, prohibitedPrivileges } = context.state\n\n /*\n **질의와 같은 문을 쓴다.**\n\n `User.hasPrivilege('query','figure',…)` 만 보면 안 된다 — figure 질의는\n `@privilege(category: \"figure\", privilege: \"query\", domainOwnerGranted: true)` 로 선언되어 있고,\n 도메인 소유자는 `figure:query` 권한 줄이 없어도 통과한다. 그 허용을 여기서 빼면 목록은 보이는데\n 그림만 403 이 되어, 화면은 「그림이 아직 없다」고 거짓말한다. [확인함] 실제로 카드 14장이 전부\n 403 이었다.\n\n 조건을 손으로 다시 적지 않고 지시자가 쓰는 함수를 그대로 쓴다. 선언이 바뀌면 여기도 함께 바뀐다.\n */\n const allowed = await checkPermission(\n { category: 'figure', privilege: 'query', owner: true },\n user,\n domain,\n unsafeIP,\n prohibitedPrivileges\n )\n\n if (!allowed) {\n context.status = 403\n return\n }\n\n await sendFigureThumbnail(context, next, context.params.id, domain)\n})\n\n/**\n * The picture of a figure the caller may read, by its hash (ADR-0104 decision 6). Exported for tests, which give\n * their own `files`.\n */\nexport async function sendFigureThumbnail(\n context: any,\n next: () => Promise<any>,\n id: string,\n domain: { id: string; parentId?: string },\n files: ThumbnailFiles = thumbnailFiles()\n): Promise<void> {\n /*\n 읽기는 부모 도메인까지 본다 — `figure` 질의와 같은 범위다. 물려받은 형상을 목록에는 보여 주고\n 그림만 404 를 내면, 화면은 「그림이 아직 없다」고 거짓말하게 된다.\n */\n const found = await getRepository(Figure).findOne({\n where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id },\n relations: { domain: true },\n select: { id: true, thumbnailHash: true, thumbnailUpdatedAt: true, domain: { id: true } }\n })\n if (!found?.thumbnailHash) {\n context.status = 404\n return\n }\n\n /*\n 그림은 **그 figure 의 도메인**에서 찾는다 — 묻는 도메인이 아니다(판정 조건 4). 해시가 같아도 다른 도메인의 행을\n 집지 않는다. 물려받은 figure 의 그림은 부모 도메인에 있다.\n */\n const picture = await thumbnailAttachmentOf({ getRepository }, found.domain!.id, found.thumbnailHash)\n if (!picture) {\n context.status = 404\n return\n }\n\n /*\n **개인 캐시만 허락한다.** 이 그림은 권한을 본 뒤에 내주는 것이므로 공용 캐시(프록시)에 남으면 안 된다 —\n attachment 의 공개 경로가 쓰는 `public` 을 따라 쓰지 않는다(판정 조건 1). 같은 내용은 같은 해시이므로 ETag 가\n 곧 내용이다. 부르는 쪽이 `?v=` 로 판을 가리므로 오래 두어도 낡은 그림이 남지 않는다.\n */\n const ours = () => {\n context.type = picture.mimetype\n context.set('Cache-Control', 'private, max-age=31536000')\n context.set('ETag', `\"${found.thumbnailHash}\"`)\n if (found.thumbnailUpdatedAt) context.set('Last-Modified', new Date(found.thumbnailUpdatedAt).toUTCString())\n }\n ours()\n /* koa answers `fresh` only for a 2xx: before a body the status is still 404, and the check never matched. */\n context.status = 200\n if (context.fresh) {\n context.status = 304\n return\n }\n await files.send(context, picture.path!, next)\n /* The storage sets its own (S3 its ETag, attachment's Cache-Control): this door's are said last. */\n ours()\n}\n\n/**\n * A recipe template's thumbnail (ADR-0095 amended): the file drawn from its model, served behind the same door as the\n * templates themselves. The file changes only with a release, so a day's private cache is enough.\n */\nfigureThumbnailRouter.get('/figure-recipe-thumbnail/:name', async context => {\n const { domain, user, unsafeIP, prohibitedPrivileges } = context.state\n const allowed = await checkPermission({ category: 'figure', privilege: 'query', owner: true }, user, domain, unsafeIP, prohibitedPrivileges)\n if (!allowed) {\n context.status = 403\n return\n }\n const template = recipeTemplates().templates.find(t => t.recipe.name === context.params.name)\n if (!template?.thumbnail) {\n context.status = 404\n return\n }\n context.type = 'image/png'\n context.set('Cache-Control', 'private, max-age=86400')\n context.body = readFileSync(template.thumbnail)\n})\n"]}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { checkPermission } from '@things-factory/auth-base';
|
|
2
|
+
/**
|
|
3
|
+
* Who may read a figure picture by its attachment path (ADR-0106 decision 2): whoever may query figures -- the same door
|
|
4
|
+
* `/figure-thumbnail/:id` keeps, so the picture is no more open by its path than by its figure. attachment-base has
|
|
5
|
+
* already checked the attachment is of the request's domain.
|
|
6
|
+
*/
|
|
7
|
+
export declare function figureThumbnailReader(_attachment: unknown, context: any, check?: typeof checkPermission): Promise<boolean>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.figureThumbnailReader = figureThumbnailReader;
|
|
4
|
+
const attachment_base_1 = require("@things-factory/attachment-base");
|
|
5
|
+
const auth_base_1 = require("@things-factory/auth-base");
|
|
6
|
+
const figure_thumbnail_store_js_1 = require("./figure-thumbnail-store.js");
|
|
7
|
+
/*
|
|
8
|
+
* Apart from the store, and loaded only by the server's entry: the store is read by pure modules (the save path,
|
|
9
|
+
* figure-ui's tests), and auth-base brings shell's routers with it.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Who may read a figure picture by its attachment path (ADR-0106 decision 2): whoever may query figures -- the same door
|
|
13
|
+
* `/figure-thumbnail/:id` keeps, so the picture is no more open by its path than by its figure. attachment-base has
|
|
14
|
+
* already checked the attachment is of the request's domain.
|
|
15
|
+
*/
|
|
16
|
+
async function figureThumbnailReader(_attachment, context, check = auth_base_1.checkPermission) {
|
|
17
|
+
const { domain, user, unsafeIP, prohibitedPrivileges } = context?.state ?? {};
|
|
18
|
+
return !!(await check({ category: 'figure', privilege: 'query', owner: true }, user, domain, unsafeIP, prohibitedPrivileges));
|
|
19
|
+
}
|
|
20
|
+
(0, attachment_base_1.registerAttachmentReader)(figure_thumbnail_store_js_1.THUMBNAIL_REF_TYPE, (attachment, context) => figureThumbnailReader(attachment, context));
|
|
21
|
+
//# sourceMappingURL=figure-thumbnail-reader.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"figure-thumbnail-reader.js","sourceRoot":"","sources":["../../../server/service/figure/figure-thumbnail-reader.ts"],"names":[],"mappings":";;AAcA,sDAGC;AAjBD,qEAA0E;AAC1E,yDAA2D;AAE3D,2EAAgE;AAEhE;;;GAGG;AACH;;;;GAIG;AACI,KAAK,UAAU,qBAAqB,CAAC,WAAoB,EAAE,OAAY,EAAE,QAAgC,2BAAe;IAC7H,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,oBAAoB,EAAE,GAAG,OAAO,EAAE,KAAK,IAAI,EAAE,CAAA;IAC7E,OAAO,CAAC,CAAC,CAAC,MAAM,KAAK,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,oBAAoB,CAAC,CAAC,CAAA;AAC/H,CAAC;AAED,IAAA,0CAAwB,EAAC,8CAAkB,EAAE,CAAC,UAAU,EAAE,OAAO,EAAE,EAAE,CAAC,qBAAqB,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC,CAAA","sourcesContent":["import { registerAttachmentReader } from '@things-factory/attachment-base'\nimport { checkPermission } from '@things-factory/auth-base'\n\nimport { THUMBNAIL_REF_TYPE } from './figure-thumbnail-store.js'\n\n/*\n * Apart from the store, and loaded only by the server's entry: the store is read by pure modules (the save path,\n * figure-ui's tests), and auth-base brings shell's routers with it.\n */\n/**\n * Who may read a figure picture by its attachment path (ADR-0106 decision 2): whoever may query figures -- the same door\n * `/figure-thumbnail/:id` keeps, so the picture is no more open by its path than by its figure. attachment-base has\n * already checked the attachment is of the request's domain.\n */\nexport async function figureThumbnailReader(_attachment: unknown, context: any, check: typeof checkPermission = checkPermission): Promise<boolean> {\n const { domain, user, unsafeIP, prohibitedPrivileges } = context?.state ?? {}\n return !!(await check({ category: 'figure', privilege: 'query', owner: true }, user, domain, unsafeIP, prohibitedPrivileges))\n}\n\nregisterAttachmentReader(THUMBNAIL_REF_TYPE, (attachment, context) => figureThumbnailReader(attachment, context))\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"figure-thumbnail-store.js","sourceRoot":"","sources":["../../../server/service/figure/figure-thumbnail-store.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHash } from 'crypto'\nimport { Readable } from 'stream'\n\nimport { Attachment, STORAGE } from '@things-factory/attachment-base'\n\n/*\n * A figure's picture, kept by attachment-base and named by its content (ADR-0104 decision 6, chief architect's ruling\n * 2026-10-02).\n *\n * stored by attachment-base's storage (file, S3, database -- whichever the install configures), one attachment\n * per (domain, content): refType `figure-thumbnail`, refBy the SHA-256 of the bytes. A figure, its\n * releases and the drafts a revert kept carry that hash, never the bytes.\n * served only through `/figure-thumbnail/:id`, behind the figure query permission -- never through attachment-\n * base's public `/attachment/:path`, which checks nothing (a known gap, recorded in ADR-0104).\n * the same content twice in one domain is one attachment, read by its hash. Two writers that both find none both\n * write one; either serves the same bytes, so the result is the same (ruling, condition 2).\n *\n * The file is written before the row that names it: a file cannot be rolled back, so it goes first, and a row that\n * fails to commit leaves a file the next write of the same content writes again.\n */\n\nexport const THUMBNAIL_REF_TYPE = 'figure-thumbnail'\n\n/** `data:image/webp;base64,....` as its type and bytes; undefined when it is not one. */\nexport function decodeDataUrl(dataUrl: string): { type: string; bytes: Buffer } | undefined {\n const matched = /^data:([^;,]+);base64,(.*)$/s.exec(dataUrl)\n return matched ? { type: matched[1]!, bytes: Buffer.from(matched[2]!, 'base64') } : undefined\n}\n\nexport const sha256Of = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex')\n\n/** Where the bytes go: attachment-base's storage. Tests give another. */\nexport interface ThumbnailFiles {\n put(bytes: Buffer, mimetype: string, name: string, context: any): Promise<{ id: string; path: string; size: number; contents?: Buffer }>\n send(koa: any, path: string, next: () => Promise<any>): Promise<void>\n}\n\nexport const attachmentFiles: ThumbnailFiles = {\n async put(bytes, mimetype, name, context) {\n const file = Promise.resolve({\n createReadStream: () => Readable.from([bytes]),\n filename: name,\n mimetype,\n encoding: 'binary'\n })\n const { id, path, size, contents } = await STORAGE.uploadFile({ file, context })\n return { id, path, size, contents }\n },\n send: (koa, path, next) => STORAGE.sendFile(koa, path, next)\n}\n\n/*\n * The files in use: attachment-base's storage, unless a test gives its own. One seam for every writer and the router,\n * so a test of a save or a revert need not reach into each.\n */\nlet inUse: ThumbnailFiles = attachmentFiles\nexport const thumbnailFiles = (): ThumbnailFiles => inUse\n/** For tests: use these files until set back; returns the ones it replaced. */\nexport function useThumbnailFiles(files: ThumbnailFiles): ThumbnailFiles {\n const was = inUse\n inUse = files\n return was\n}\n\ntype Tx = { getRepository: (target: any) => any }\n\n/** The attachment holding this content in this domain, or undefined. */\nexport async function thumbnailAttachmentOf(tx: Tx, domainId: string, hash: string): Promise<Attachment | undefined> {\n return (await tx.getRepository(Attachment).findOne({ where: { domain: { id: domainId }, refType: THUMBNAIL_REF_TYPE, refBy: hash } })) ?? undefined\n}\n\n/**\n * Keeps a picture and gives its hash. Refused when it is not a data URL -- a picture the server cannot read is one it\n * must not keep under a name.\n */\nexport async function keepThumbnail(\n tx: Tx,\n domain: { id: string },\n user: unknown,\n dataUrl: string,\n files: ThumbnailFiles = thumbnailFiles()\n): Promise<string> {\n const decoded = decodeDataUrl(dataUrl)\n if (!decoded) throw new Error('썸네일은 data URL(data:image/...;base64,...) 이어야 한다')\n const hash = sha256Of(decoded.bytes)\n if (await thumbnailAttachmentOf(tx, domain.id, hash)) return hash\n const ext = decoded.type.split('/')[1]?.split('+')[0] || 'bin'\n const name = `${hash}.${ext}`\n /* The file first: it cannot be rolled back. The row that names it comes after, in the caller's transaction. */\n const put = await files.put(decoded.bytes, decoded.type, name, { state: { domain, user, tx } })\n await tx.getRepository(Attachment).save({\n domain,\n creator: user,\n updater: user,\n id: put.id,\n name,\n mimetype: decoded.type,\n encoding: 'binary',\n refType: THUMBNAIL_REF_TYPE,\n refBy: hash,\n category: 'image',\n size: String(put.size),\n path: put.path,\n ...(put.contents ? { contents: put.contents } : {})\n })\n return hash\n}\n"]}
|
|
1
|
+
{"version":3,"file":"figure-thumbnail-store.js","sourceRoot":"","sources":["../../../server/service/figure/figure-thumbnail-store.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHash } from 'crypto'\nimport { Readable } from 'stream'\n\nimport { Attachment, STORAGE } from '@things-factory/attachment-base'\n\n/*\n * A figure's picture, kept by attachment-base and named by its content (ADR-0104 decision 6, chief architect's ruling\n * 2026-10-02).\n *\n * stored by attachment-base's storage (file, S3, database -- whichever the install configures), one attachment\n * per (domain, content): refType `figure-thumbnail`, refBy the SHA-256 of the bytes. A figure, its\n * releases and the drafts a revert kept carry that hash, never the bytes.\n * served only through `/figure-thumbnail/:id`, behind the figure query permission -- never through attachment-\n * base's public `/attachment/:path`, which checks nothing (a known gap, recorded in ADR-0104).\n * the same content twice in one domain is one attachment, read by its hash. Two writers that both find none both\n * write one; either serves the same bytes, so the result is the same (ruling, condition 2).\n *\n * The file is written before the row that names it: a file cannot be rolled back, so it goes first, and a row that\n * fails to commit leaves a file the next write of the same content writes again.\n */\n\nexport const THUMBNAIL_REF_TYPE = 'figure-thumbnail'\n\n/** `data:image/webp;base64,....` as its type and bytes; undefined when it is not one. */\nexport function decodeDataUrl(dataUrl: string): { type: string; bytes: Buffer } | undefined {\n const matched = /^data:([^;,]+);base64,(.*)$/s.exec(dataUrl)\n return matched ? { type: matched[1]!, bytes: Buffer.from(matched[2]!, 'base64') } : undefined\n}\n\nexport const sha256Of = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex')\n\n/** Where the bytes go: attachment-base's storage. Tests give another. */\nexport interface ThumbnailFiles {\n put(bytes: Buffer, mimetype: string, name: string, context: any): Promise<{ id: string; path: string; size: number; contents?: Buffer }>\n send(koa: any, path: string, next: () => Promise<any>): Promise<void>\n}\n\nexport const attachmentFiles: ThumbnailFiles = {\n async put(bytes, mimetype, name, context) {\n const file = Promise.resolve({\n createReadStream: () => Readable.from([bytes]),\n filename: name,\n mimetype,\n encoding: 'binary'\n })\n const { id, path, size, contents } = await STORAGE.uploadFile({ file, context })\n return { id, path, size, contents }\n },\n send: (koa, path, next) => STORAGE.sendFile(koa, path, next)\n}\n\n/*\n * The files in use: attachment-base's storage, unless a test gives its own. One seam for every writer and the router,\n * so a test of a save or a revert need not reach into each.\n */\nlet inUse: ThumbnailFiles = attachmentFiles\nexport const thumbnailFiles = (): ThumbnailFiles => inUse\n/** For tests: use these files until set back; returns the ones it replaced. */\nexport function useThumbnailFiles(files: ThumbnailFiles): ThumbnailFiles {\n const was = inUse\n inUse = files\n return was\n}\n\ntype Tx = { getRepository: (target: any) => any }\n\n/** The attachment holding this content in this domain, or undefined. */\nexport async function thumbnailAttachmentOf(tx: Tx, domainId: string, hash: string): Promise<Attachment | undefined> {\n return (await tx.getRepository(Attachment).findOne({ where: { domain: { id: domainId }, refType: THUMBNAIL_REF_TYPE, refBy: hash } })) ?? undefined\n}\n\n/**\n * Keeps a picture and gives its hash. Refused when it is not a data URL -- a picture the server cannot read is one it\n * must not keep under a name.\n */\nexport async function keepThumbnail(\n tx: Tx,\n domain: { id: string },\n user: unknown,\n dataUrl: string,\n files: ThumbnailFiles = thumbnailFiles()\n): Promise<string> {\n const decoded = decodeDataUrl(dataUrl)\n if (!decoded) throw new Error('썸네일은 data URL(data:image/...;base64,...) 이어야 한다')\n const hash = sha256Of(decoded.bytes)\n if (await thumbnailAttachmentOf(tx, domain.id, hash)) return hash\n const ext = decoded.type.split('/')[1]?.split('+')[0] || 'bin'\n const name = `${hash}.${ext}`\n /* The file first: it cannot be rolled back. The row that names it comes after, in the caller's transaction. */\n const put = await files.put(decoded.bytes, decoded.type, name, { state: { domain, user, tx } })\n await tx.getRepository(Attachment).save({\n domain,\n creator: user,\n updater: user,\n id: put.id,\n name,\n mimetype: decoded.type,\n encoding: 'binary',\n refType: THUMBNAIL_REF_TYPE,\n refBy: hash,\n category: 'image',\n size: String(put.size),\n path: put.path,\n ...(put.contents ? { contents: put.contents } : {})\n })\n return hash\n}\n\n"]}
|