@ryusenkei/maezae 0.17.0 → 0.18.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.md CHANGED
@@ -181,6 +181,24 @@ const html = bodyToHtml(entry.body, {
181
181
 
182
182
  章の分割などで見出しの文字を使うときは、`plainOf(heading.content)` で文字にする(取り込みは見出しの文字を変えない)
183
183
 
184
+ ## 非公開のメディア(0.18.0 から)
185
+
186
+ 非公開の項目(型の宣言で `visibility: 'private'` のメディアと本文)の画像とファイルは、署名つき・期限つきの URL でだけ配信する。配信 API は `visibility: 'private'` と署名のない `url` を返すので、権利のある人に見せるときにサイトのサーバーが署名する
187
+
188
+ - 鍵: 媒体の管理画面の「設定 → 連携」の非公開のメディアの署名の鍵。サイトの秘密値に入れ、ブラウザに出さない
189
+ - 期限: `expiresInSec`(既定は3600秒)。ページを表示するたびに署名し、写しに署名つきの URL を保存しない
190
+ - 画像の変換のクエリ(`w` など)は署名の後に足してよい(署名の対象はパスと期限だけ)
191
+ - 署名のない URL は404、期限切れと別の鍵の署名は403。鍵を作り直すと古い鍵の署名はすぐ通らない
192
+
193
+ ```ts
194
+ import { signMediaUrl, withImageParams } from '@ryusenkei/maezae/delivery';
195
+
196
+ const src = image.visibility === 'private' ? await signMediaUrl(image.url, env.MEDIA_SIGNING_KEY, { expiresInSec: 1800 }) : image.url;
197
+ const thumb = withImageParams(src, { w: 400, auto: 'format' });
198
+ ```
199
+
200
+ ファイル(`kind: 'file'`): `url` は `/files/<媒体>/<ID>.<拡張子>`、`name`(元のファイル名)・`mimeType`・`size` を持つ。本文のファイルのブロックも同じ実体を参照する
201
+
184
202
  ## プレビュー
185
203
 
186
204
  - CMS は媒体のサイトのオリジンへ、公開のページと同じパスで署名つきのリクエストを送信
package/dist/delivery.js CHANGED
@@ -250,12 +250,22 @@ const EXTENSIONS = {
250
250
  "image/webp": "webp",
251
251
  "image/gif": "gif",
252
252
  "video/mp4": "mp4",
253
- "video/webm": "webm"
253
+ "video/webm": "webm",
254
+ "application/pdf": "pdf",
255
+ "application/zip": "zip",
256
+ "text/plain": "txt",
257
+ "text/csv": "csv",
258
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
259
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
260
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx"
254
261
  };
262
+ /** 形式の実体の種類 */
263
+ const mediaKindOf = (mimeType) => mimeType.startsWith("image/") ? "image" : mimeType.startsWith("video/") ? "video" : "file";
255
264
  /** 拡張子の形式(配信しない拡張子は null) */
256
265
  const mediaMimeOf = (ext) => Object.entries(EXTENSIONS).find(([, e]) => e === ext)?.[0] ?? null;
257
266
  const IMAGE_PATH = /^\/images\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_-]+)-(\d+)x(\d+)\.([a-z0-9]+)$/;
258
267
  const VIDEO_PATH = /^\/videos\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_-]+)\.([a-z0-9]+)$/;
268
+ const FILE_PATH = /^\/files\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_-]+)\.([a-z0-9]+)$/;
259
269
  /** 配信 URL のパスを解析(形が違えば null) */
260
270
  function parseMediaPath(pathname) {
261
271
  const image = IMAGE_PATH.exec(pathname);
@@ -280,8 +290,39 @@ function parseMediaPath(pathname) {
280
290
  ext
281
291
  } : null;
282
292
  }
293
+ const file = FILE_PATH.exec(pathname);
294
+ if (file) {
295
+ const [, project, id, ext] = file;
296
+ const mime = mediaMimeOf(ext);
297
+ return mime && mediaKindOf(mime) === "file" ? {
298
+ kind: "file",
299
+ project,
300
+ id,
301
+ ext
302
+ } : null;
303
+ }
283
304
  return null;
284
305
  }
306
+ const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
307
+ /** 署名(HMAC-SHA256、対象はパスと期限、変換のクエリは含めない) */
308
+ async function signatureOf(secret, pathname, exp) {
309
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), {
310
+ name: "HMAC",
311
+ hash: "SHA-256"
312
+ }, false, ["sign"]);
313
+ return hex(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${pathname}:${exp}`)));
314
+ }
315
+ /**
316
+ * 非公開のメディアの署名つきの URL(exp と sig のクエリを付ける、ほかのクエリは残す)
317
+ * - secret: 媒体の非公開のメディアの署名の鍵、expiresInSec: 期限までの秒、now: 今の UNIX ミリ秒
318
+ */
319
+ async function signMediaUrl(url, secret, options = {}) {
320
+ const u = new URL(url);
321
+ const exp = Math.floor((options.now ?? Date.now()) / 1e3) + (options.expiresInSec ?? 3600);
322
+ u.searchParams.set("exp", String(exp));
323
+ u.searchParams.set("sig", await signatureOf(secret, u.pathname, exp));
324
+ return u.toString();
325
+ }
285
326
  /** 幅・高さの上限(画素) */
286
327
  const IMAGE_SIZE_MAX = 4096;
287
328
  const FITS = [
@@ -432,4 +473,4 @@ function createDeliveryClock(timeZone) {
432
473
  })
433
474
  };
434
475
  }
435
- export { DELIVERY_PATH, DeliveryError, cachedDelivery, createDeliveryClient, createDeliveryClock, deliveredImageUrl, fieldOf, imageParamsOf, limitOf, listParams, mediaOf, memoryDeliveryCache, optionLabels, pagePath, parseMediaPath, readChanges, subPath, withImageParams };
476
+ export { DELIVERY_PATH, DeliveryError, cachedDelivery, createDeliveryClient, createDeliveryClock, deliveredImageUrl, fieldOf, imageParamsOf, limitOf, listParams, mediaOf, memoryDeliveryCache, optionLabels, pagePath, parseMediaPath, readChanges, signMediaUrl, subPath, withImageParams };
package/dist/model.js CHANGED
@@ -344,11 +344,13 @@ const fieldUnion = z.discriminatedUnion("type", [
344
344
  type: z.literal("media"),
345
345
  accept: z.array(z.enum(["image", "video"])).optional(),
346
346
  multiple: z.boolean().optional(),
347
- frames: z.array(frame).optional()
347
+ frames: z.array(frame).optional(),
348
+ visibility: z.literal("private").optional()
348
349
  }),
349
350
  z.object({
350
351
  ...base,
351
352
  type: z.literal("body"),
353
+ visibility: z.literal("private").optional(),
352
354
  profile: z.object({
353
355
  headings: z.array(z.number()),
354
356
  marks: z.array(z.string()),
@@ -8,3 +8,5 @@ export declare const ASSET_BULK_MAX = 500;
8
8
  export declare const ASSET_IMAGE_TYPES: readonly ["image/jpeg", "image/png", "image/webp", "image/gif"];
9
9
  /** 画像1枚の大きさの上限(バイト、アップロード・取り込みで共通) */
10
10
  export declare const ASSET_SIZE_MAX: number;
11
+ /** ファイルの実体の大きさの上限(バイト、本文の中のファイルの取り込み) */
12
+ export declare const ASSET_FILE_SIZE_MAX: number;
@@ -10,6 +10,7 @@ export declare const ERROR_STATUS: {
10
10
  readonly file_required: 400;
11
11
  readonly file_too_large: 400;
12
12
  readonly unsupported_image: 400;
13
+ readonly unsupported_file: 400;
13
14
  readonly invalid_dimensions: 400;
14
15
  readonly https_required: 400;
15
16
  readonly invalid_style: 400;
@@ -74,7 +75,7 @@ export type ErrorParams = {
74
75
  /** 状態の不一致の理由: invalid_state */
75
76
  reason?: 'not_draft' | 'no_draft' | 'not_in_review' | 'request_handled' | 'deleted' | 'not_deleted' | 'schema_confirm' | 'no_publishing' | 'no_review' | 'not_published';
76
77
  /** 取得の対象: upstream_failed */
77
- resource?: 'page' | 'image' | 'webhook' | 'microcms';
78
+ resource?: 'page' | 'image' | 'webhook' | 'microcms' | 'file';
78
79
  /**
79
80
  * 名前
80
81
  * - no_linked_entry: 結び付くコンテンツタイプの表示名、account_not_found: メールアドレス
@@ -107,6 +107,7 @@ export type MicroCmsApiInput = Readonly<{
107
107
  format: 'list' | 'object';
108
108
  schema: unknown;
109
109
  relations: Readonly<Record<string, string>>;
110
+ privateMedia?: boolean;
110
111
  }>;
111
112
  /** microCMS の型の変換の注意(path は型の id と項目) */
112
113
  export type MicroCmsNote = Readonly<{
@@ -1,5 +1,5 @@
1
1
  export { API_KEY_ALL_TYPES, API_KEY_KINDS, API_KEY_OPERATIONS, API_KEY_RATE, API_KEY_WRITE_OPERATIONS, PUBLIC_FAILURE_RATE, apiKeyAllows, type ApiKey, type ApiKeyKind, type ApiKeyOperation, type ApiKeyPermissions, DEFAULT_API_KEY_PERMISSIONS } from './apiKeys.js';
2
- export { ASSET_BULK_MAX, ASSET_IMAGE_TYPES, ASSET_SIZE_MAX } from './assets.js';
2
+ export { ASSET_BULK_MAX, ASSET_FILE_SIZE_MAX, ASSET_IMAGE_TYPES, ASSET_SIZE_MAX } from './assets.js';
3
3
  export { errorLabel, errorTitle, mergeMessages, TARGET_LABELS, targetOf, type TargetMessages } from './errorLabels.js';
4
4
  export { ERROR_STATUS, JSON_BODY_MAX, type ErrorBody, type ErrorCode, type ErrorParams, type ErrorTarget } from './errors.js';
5
5
  export { CHANGES_LABELS, STATUS } from './labels.js';
@@ -25,7 +25,7 @@ export type LocalImage = Readonly<{
25
25
  height: number;
26
26
  format: string;
27
27
  }>;
28
- /** 実体の共通の項目(ID は UUID で R2 のキーと同じ、sha256 は内容のハッシュで同じ画像の重複を防止、ファイル名は保存しない) */
28
+ /** 実体の共通の項目(ID は UUID で R2 のキーと同じ、sha256 は内容のハッシュで同じ画像の重複を防止、visibility は非公開の実体だけ private) */
29
29
  type AssetBase = Readonly<{
30
30
  id: string;
31
31
  mimeType: string;
@@ -37,6 +37,7 @@ type AssetBase = Readonly<{
37
37
  hotspot?: Hotspot;
38
38
  crop?: Crop;
39
39
  createdAt: string;
40
+ visibility?: 'private';
40
41
  }>;
41
42
  /** 画像の実体(Sanity の sanity.imageAsset、代替テキストと hotspot は画像ごとに1つ) */
42
43
  export type ImageAsset = AssetBase & Readonly<{
@@ -48,8 +49,13 @@ export type VideoAsset = AssetBase & Readonly<{
48
49
  duration?: number;
49
50
  poster?: string;
50
51
  }>;
52
+ /** ファイルの実体(本文の中のファイル、name は元のファイル名、寸法は0) */
53
+ export type FileAsset = AssetBase & Readonly<{
54
+ kind: 'file';
55
+ name?: string;
56
+ }>;
51
57
  /** 実体(CMS のメディアにある1件) */
52
- export type Asset = ImageAsset | VideoAsset;
58
+ export type Asset = ImageAsset | VideoAsset | FileAsset;
53
59
  /** 使用する側が保存する実体の参照(Sanity の image の asset、表示のときに実体の寸法・代替テキスト・hotspot を合わせて Media に解決) */
54
60
  export type AssetRef = Readonly<{
55
61
  asset: string;
@@ -2,7 +2,7 @@ export { fieldZod, jsonSchemaOf, normalizeValues, zodOf, type ValueOptions } fro
2
2
  export { assetRefSchema, cropSchema, hotspotSchema, mediaSchema, type Asset, type AssetRef, type Crop, type Hotspot, type LocalImage, type Media } from './asset.js';
3
3
  export { coverPosition, coverStart, cropRect, croppedHotspot, objectPosition, stillOf, type CropRect } from './image.js';
4
4
  export { IMAGE_TYPE, VIDEO_TYPE } from './media.js';
5
- export { IMAGE_SIZE_MAX, imageParamsOf, mediaExtOf, mediaMimeOf, mediaPathOf, parseMediaPath, withImageParams, type ImageParams, type MediaPath } from './mediaUrl.js';
5
+ export { IMAGE_SIZE_MAX, imageParamsOf, MEDIA_SIGNING_KEY_BLOB, mediaBlobKeyOf, mediaExtOf, mediaKindOf, mediaMimeOf, mediaPathOf, parseMediaPath, signMediaUrl, verifyMediaUrl, withImageParams, type ImageParams, type AssetKind, type MediaPath } from './mediaUrl.js';
6
6
  export { diffSchema, renameType, type ChangeKind, type ChangeLevel, type DiffOptions, type SchemaChange } from './changes.js';
7
7
  export { dateRoleFieldsOf, primaryTypeOf, requiredPrimaryTypeOf, SOURCES_FIELD, sourceIdsOf, sourcesFieldOf, sourceTypeOf, type SourcesField } from './capabilities.js';
8
8
  export { appliesTo, checkFields, fieldsFor } from './check.js';
@@ -1,11 +1,15 @@
1
1
  import type { CropRect } from './image.js';
2
+ /** 実体の種類 */
3
+ export type AssetKind = 'image' | 'video' | 'file';
4
+ /** 形式の実体の種類 */
5
+ export declare const mediaKindOf: (mimeType: string) => AssetKind;
2
6
  /** 形式の拡張子(配信しない形式は null) */
3
7
  export declare const mediaExtOf: (mimeType: string) => string | null;
4
8
  /** 拡張子の形式(配信しない拡張子は null) */
5
9
  export declare const mediaMimeOf: (ext: string) => string | null;
6
10
  /** 配信 URL のパスの要素(寸法は画像だけ) */
7
11
  export type MediaPath = Readonly<{
8
- kind: 'image' | 'video';
12
+ kind: AssetKind;
9
13
  project: string;
10
14
  id: string;
11
15
  ext: string;
@@ -14,7 +18,7 @@ export type MediaPath = Readonly<{
14
18
  }>;
15
19
  /** 配信 URL のパス(媒体・ID の形が不正、配信しない形式なら null) */
16
20
  export declare function mediaPathOf({ kind, project, id, mimeType, width, height }: Readonly<{
17
- kind: 'image' | 'video';
21
+ kind: AssetKind;
18
22
  project: string;
19
23
  id: string;
20
24
  mimeType: string;
@@ -23,6 +27,20 @@ export declare function mediaPathOf({ kind, project, id, mimeType, width, height
23
27
  }>): string | null;
24
28
  /** 配信 URL のパスを解析(形が違えば null) */
25
29
  export declare function parseMediaPath(pathname: string): MediaPath | null;
30
+ /** 保存先のキー(媒体の接頭辞の後、非公開は private/ の下) */
31
+ export declare const mediaBlobKeyOf: (id: string, visibility: "public" | "private") => string;
32
+ /** 非公開のメディアの署名の鍵の保存先のキー(媒体の接頭辞の後、配信 URL の ID の形にない「.」で始め、パスから届かない) */
33
+ export declare const MEDIA_SIGNING_KEY_BLOB = ".media-signing";
34
+ /**
35
+ * 非公開のメディアの署名つきの URL(exp と sig のクエリを付ける、ほかのクエリは残す)
36
+ * - secret: 媒体の非公開のメディアの署名の鍵、expiresInSec: 期限までの秒、now: 今の UNIX ミリ秒
37
+ */
38
+ export declare function signMediaUrl(url: string, secret: string, options?: Readonly<{
39
+ expiresInSec?: number;
40
+ now?: number;
41
+ }>): Promise<string>;
42
+ /** 署名つきの URL の検査(期限の内で、どれかの鍵の署名と一致すれば期限の UNIX 秒、ほかは null) */
43
+ export declare function verifyMediaUrl(url: URL, secrets: readonly string[], now?: number): Promise<number | null>;
26
44
  /** 画像の変換のクエリ(w・h は表示の画素、rect は元の画像の画素での切り抜き、auto=format はブラウザに合わせた形式) */
27
45
  export type ImageParams = Readonly<{
28
46
  w?: number;
@@ -112,10 +112,12 @@ export type FieldDef = FieldBase & Readonly<{
112
112
  type: 'media'; /** 受け付けるメディアの種類(なしはすべて) */
113
113
  accept?: readonly MediaKind[];
114
114
  multiple?: boolean; /** 画像の枠(先頭から条件に合うもの、page.ts) */
115
- frames?: readonly ImageFrameDef[];
115
+ frames?: readonly ImageFrameDef[]; /** 非公開のメディアだけ(アップロードと取り込みは非公開、公開の実体は保存で拒否) */
116
+ visibility?: 'private';
116
117
  } & Counts) | {
117
118
  type: 'body';
118
- profile: BodyProfile;
119
+ profile: BodyProfile; /** 本文の画像とファイルは非公開のメディアだけ */
120
+ visibility?: 'private';
119
121
  } | ({
120
122
  type: 'group'; /** まとまりの中の項目(Sanity の object、microCMS のカスタムフィールド) */
121
123
  fields: readonly FieldDef[];
@@ -2,7 +2,7 @@ export { cachedDelivery, memoryDeliveryCache, type DeliveryCache } from './cache
2
2
  export { readChanges, type Change, type ChangesPage } from './changes.js';
3
3
  export { createDeliveryClient, DELIVERY_PATH, DeliveryError, subPath, type BundleResult, type Delivery, type DeliveryClient, type Prefetched } from './client.js';
4
4
  export { deliveredImageUrl, mediaOf, type ImageUrl, type MediaOf } from './media.js';
5
- export { imageParamsOf, parseMediaPath, withImageParams, type ImageParams, type MediaPath } from '../../../model/src/index.js';
5
+ export { imageParamsOf, parseMediaPath, signMediaUrl, withImageParams, type ImageParams, type MediaPath } from '../../../model/src/index.js';
6
6
  export type { FieldPath, Selected } from './fields.js';
7
7
  export { listParams, type ListFilter, type ListQuery, type Page } from './query.js';
8
8
  export { createDeliveryClock, fieldOf, limitOf, optionLabels, pagePath, type DeliveryClock } from './schema.js';
@@ -5,14 +5,18 @@ export type Item = Readonly<Record<string, unknown>>;
5
5
  /** 配信 API が解決した実体(url は配信 URL で配信のオリジンがある CMS だけ、hotspot・crop は元の画像に対する割合) */
6
6
  export type DeliveredAsset = Readonly<{
7
7
  id: string;
8
- kind: 'image' | 'video';
8
+ kind: 'image' | 'video' | 'file';
9
9
  url?: string;
10
10
  width: number;
11
11
  height: number;
12
12
  alt: string;
13
13
  hotspot?: Hotspot;
14
14
  crop?: Crop;
15
- poster?: DeliveredAsset | null;
15
+ poster?: DeliveredAsset | null; /** 非公開の実体(url は署名のない配信 URL、signMediaUrl で署名して渡す) */
16
+ visibility?: 'private'; /** ファイルの元のファイル名・形式・大きさ(バイト) */
17
+ name?: string;
18
+ mimeType?: string;
19
+ size?: number;
16
20
  }>;
17
21
  /** 項目の1つ分の値(複数の値を持つ項目は FieldValue が配列に、query.ts の絞り込みの値と共通) */
18
22
  export type SingleValue<F extends FieldDef> = F extends {
@@ -1,2 +1,2 @@
1
- export { cachedDelivery, createDeliveryClient, createDeliveryClock, deliveredImageUrl, DELIVERY_PATH, DeliveryError, fieldOf, imageParamsOf, limitOf, listParams, mediaOf, memoryDeliveryCache, optionLabels, pagePath, parseMediaPath, readChanges, subPath, withImageParams } from './delivery/index.js';
1
+ export { cachedDelivery, createDeliveryClient, createDeliveryClock, deliveredImageUrl, DELIVERY_PATH, DeliveryError, fieldOf, imageParamsOf, limitOf, listParams, mediaOf, memoryDeliveryCache, optionLabels, pagePath, parseMediaPath, readChanges, signMediaUrl, subPath, withImageParams } from './delivery/index.js';
2
2
  export type { BundleResult, Change, ChangesPage, DeliveredAsset, Delivery, DeliveryCache, DeliveryClient, DeliveryClock, FieldValue, ImageParams, ImageUrl, Item, ItemMeta, ItemOf, FieldPath, ListFilter, ListQuery, MediaOf, MediaPath, Page, Prefetched, Selected } from './delivery/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryusenkei/maezae",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "maezae の導入先のサイト向けの SDK(配信 API の取得・本文の型・プレビューの署名・手元の開発の配信 API)",
5
5
  "license": "UNLICENSED",
6
6
  "author": "RYUSENKEI",