@withwiz/gallery 0.1.0 → 0.2.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.ko.md CHANGED
@@ -97,6 +97,17 @@ export const galleryConfig: GalleryConfig = {
97
97
  },
98
98
  revalidate: revalidatePath,
99
99
  revalidatePaths: ["/", "/home/v1"],
100
+ // 선택 — 생략하면 apiWrapper 를 통과한 사용자 전원에게 모든 권한 부여
101
+ permissions: {
102
+ canEdit: (ctx, g) => ctx.user?.role === "owner" || g.authorId === ctx.user?.id,
103
+ canDelete: (ctx, g) => ctx.user?.role === "owner" || g.authorId === ctx.user?.id,
104
+ canManageCategories: (ctx) => ctx.user?.role === "owner",
105
+ },
106
+ // 선택 — 생략하면 안전한 기본값 적용 ("보안 참고" 절 참조)
107
+ validation: {
108
+ imageUrlHosts: ["cdn.example.com"],
109
+ imageKeyPattern: /^gallery\/[A-Za-z0-9._-]+$/,
110
+ },
100
111
  storage: {
101
112
  isEnabled: isR2Enabled,
102
113
  collectKeys: collectR2Keys,
@@ -386,6 +397,15 @@ function PublicGalleryMosaic(props: {
386
397
  - **Storage / auth / revalidate 는 호스트 책임** — 본 패키지는 키/경로만 다루고 실제 R2/S3/로컬 호출은 호스트의 `config.storage.deleteKeys` 등에 위임.
387
398
  - **`presets/ballet` 에서 `next/image`, `next/navigation`, `useI18n` 직접 의존 없음** — 이미지는 plain `<img>`, 라벨은 props.
388
399
 
400
+ ## 보안 참고
401
+
402
+ - **인증은 전적으로 호스트의 `apiWrapper` 책임이다.** 비공개 항목까지 반환하는 목록 GET 을 포함해 모든 admin 라우트는 래퍼가 통과시킨 요청을 그대로 신뢰한다. 래퍼는 관리자 세션을 검증해야 하며, admin 컴포넌트가 쿠키를 함께 전송하므로(`credentials: "include"`) CSRF 방어도 갖춰야 한다.
403
+ - **`permissions` 훅은 bulk 라우트에도 적용된다.** `canDelete` 는 collection `DELETE`(ids) 를, `canEdit` 는 `bulk PATCH` 를 검사한다. 대상 전원이 통과해야 하며 하나라도 거부되면 요청 전체가 403 으로 거부된다(부분 성공 없음). `canManageCategories` 는 카테고리 생성 / 수정 / 삭제를 검사한다.
404
+ - **`imageUrl` 은 기본적으로 `https:` / `http:` 프로토콜만 허용한다** (`javascript:`, `data:` 거부). `validation.imageUrlProtocols` / `validation.imageUrlHosts` 로 더 좁힐 수 있다.
405
+ - **`imageKey` 는 `storage.deleteKeys` 에 전달되기 전에 형식 검증을 거친다.** 기본 패턴은 `[A-Za-z0-9._-/]` 만 허용하고 선행 슬래시, `//`, `..` 세그먼트를 금지하며 512자로 제한한다. 버킷 접두사를 강제하려면 `validation.imageKeyPattern` 을 지정한다. `storage.collectKeys` 도 키를 신뢰하지 않는 방향으로 구현해야 한다.
406
+ - **`search` 는 DB 조회 전에 `validation.searchMaxLength`(기본 100) 로 잘린다.**
407
+ - **peer 의존성 `next` 는 패치된 릴리스를 유지한다.** 본 패키지는 버전을 고정하지 않으므로 호스트에서 `npm audit` 을 수행해야 한다.
408
+
389
409
  ## CSS 커스터마이즈
390
410
 
391
411
  호스트의 root CSS 에서 CSS variable 을 override.
@@ -455,7 +475,6 @@ node-packages/withwiz-gallery/
455
475
  │ ├── gallery.schema.prisma # GalleryCategory + Gallery 모델 partial
456
476
  │ └── migrations/
457
477
  │ └── 2026-05-24-enum-to-table.sql # ballet enum → table backfill
458
- ├── docs/superpowers/{specs,plans}/ # 설계 문서 + sprint contracts/reports
459
478
  └── src/
460
479
  ├── index.ts # main entry — config, types, errors, utils
461
480
  ├── config.ts
package/README.md CHANGED
@@ -102,6 +102,17 @@ export const galleryConfig: GalleryConfig = {
102
102
  collectKeys: collectR2Keys,
103
103
  deleteKeys: deleteR2Keys,
104
104
  },
105
+ // optional — omit to allow every apiWrapper-authenticated user everything
106
+ permissions: {
107
+ canEdit: (ctx, g) => ctx.user?.role === "owner" || g.authorId === ctx.user?.id,
108
+ canDelete: (ctx, g) => ctx.user?.role === "owner" || g.authorId === ctx.user?.id,
109
+ canManageCategories: (ctx) => ctx.user?.role === "owner",
110
+ },
111
+ // optional — safe defaults apply when omitted (see "Security notes")
112
+ validation: {
113
+ imageUrlHosts: ["cdn.example.com"],
114
+ imageKeyPattern: /^gallery\/[A-Za-z0-9._-]+$/,
115
+ },
105
116
  i18n: {
106
117
  "admin.title": "Gallery",
107
118
  "admin.newButton": "New image",
@@ -386,6 +397,15 @@ function PublicGalleryMosaic(props: {
386
397
  - **Storage / auth / revalidate are the host's responsibility** — this package only deals with keys/paths; actual R2/S3/local calls are delegated to the host via `config.storage.deleteKeys`, etc.
387
398
  - **`presets/ballet` does not import `next/image`, `next/navigation`, or `useI18n`** — images use plain `<img>`, labels come from props.
388
399
 
400
+ ## Security notes
401
+
402
+ - **Authentication is entirely the host's `apiWrapper`.** Every admin route (including the list GET that returns unpublished items) trusts whatever the wrapper lets through. The wrapper must enforce an admin session and, because the admin components send cookies (`credentials: "include"`), CSRF protection.
403
+ - **`permissions` hooks apply to bulk routes too.** `canDelete` gates the collection `DELETE` (ids) and `canEdit` gates `bulk PATCH`; every target must pass or the whole request is rejected with 403 (no partial success). `canManageCategories` gates category create / update / delete.
404
+ - **`imageUrl` is protocol-restricted** to `https:` / `http:` by default (`javascript:` and `data:` are rejected). Set `validation.imageUrlProtocols` / `validation.imageUrlHosts` to tighten further.
405
+ - **`imageKey` is validated before it reaches `storage.deleteKeys`.** The default pattern allows `[A-Za-z0-9._-/]`, forbids a leading slash, `//` and `..` segments, and caps at 512 chars. Pass `validation.imageKeyPattern` to enforce your bucket prefix; `storage.collectKeys` should still treat the key as untrusted.
406
+ - **`search` is truncated** to `validation.searchMaxLength` (default 100) before hitting the database.
407
+ - **Keep the peer `next` on a patched release.** The package pins nothing; run `npm audit` in the host.
408
+
389
409
  ## CSS customization
390
410
 
391
411
  Override the CSS variables from the host's root CSS.
@@ -455,7 +475,6 @@ node-packages/withwiz-gallery/
455
475
  │ ├── gallery.schema.prisma # GalleryCategory + Gallery partial models
456
476
  │ └── migrations/
457
477
  │ └── 2026-05-24-enum-to-table.sql # ballet enum → table backfill
458
- ├── docs/superpowers/{specs,plans}/ # design docs + sprint contracts/reports
459
478
  └── src/
460
479
  ├── index.ts # main entry — config, types, errors, utils
461
480
  ├── config.ts
@@ -0,0 +1,78 @@
1
+ // src/validators/index.ts
2
+ import { z } from "zod";
3
+ var DEFAULT_IMAGE_URL_PROTOCOLS = ["https:", "http:"];
4
+ var DEFAULT_IMAGE_KEY_MAX_LENGTH = 512;
5
+ var DEFAULT_IMAGE_KEY_PATTERN = /^(?!.*(?:^|\/)\.\.(?:\/|$))(?!.*\/\/)[A-Za-z0-9][A-Za-z0-9._\-/]*$/;
6
+ function buildImageUrlSchema(opts) {
7
+ const protocols = new Set(
8
+ (opts.imageUrlProtocols ?? DEFAULT_IMAGE_URL_PROTOCOLS).map(
9
+ (p) => p.endsWith(":") ? p.toLowerCase() : `${p.toLowerCase()}:`
10
+ )
11
+ );
12
+ const hosts = opts.imageUrlHosts ? new Set(opts.imageUrlHosts.map((h) => h.toLowerCase())) : null;
13
+ return z.string().max(2048).url().refine(
14
+ (value) => {
15
+ let parsed;
16
+ try {
17
+ parsed = new URL(value);
18
+ } catch {
19
+ return false;
20
+ }
21
+ if (!protocols.has(parsed.protocol)) return false;
22
+ if (hosts && !hosts.has(parsed.hostname.toLowerCase())) return false;
23
+ return true;
24
+ },
25
+ { message: "imageUrl \uC758 \uD504\uB85C\uD1A0\uCF5C \uB610\uB294 \uD638\uC2A4\uD2B8\uAC00 \uD5C8\uC6A9 \uBAA9\uB85D\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4" }
26
+ );
27
+ }
28
+ function createGallerySchemas(opts) {
29
+ const imageKeyPattern = opts.imageKeyPattern ?? DEFAULT_IMAGE_KEY_PATTERN;
30
+ const ImageUrlSchema = buildImageUrlSchema(opts);
31
+ const ImageKeySchema = z.string().min(1).max(DEFAULT_IMAGE_KEY_MAX_LENGTH).regex(imageKeyPattern, "imageKey \uD615\uC2DD\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
32
+ const CreateGallerySchema = z.object({
33
+ imageUrl: ImageUrlSchema,
34
+ imageKey: ImageKeySchema.optional(),
35
+ caption: z.string().max(opts.captionMaxLength).optional(),
36
+ categoryId: z.string().cuid(),
37
+ sortOrder: z.number().int().nonnegative().default(0),
38
+ featured: z.boolean().default(false),
39
+ published: z.boolean().default(false)
40
+ });
41
+ const UpdateGallerySchema = CreateGallerySchema.partial();
42
+ const BatchCreateGallerySchema = z.object({
43
+ items: z.array(CreateGallerySchema).min(1).max(opts.batchMax)
44
+ });
45
+ const BulkUpdateSchema = z.object({
46
+ ids: z.array(z.string().cuid()).min(1).max(opts.batchMax),
47
+ published: z.boolean().optional(),
48
+ featured: z.boolean().optional()
49
+ });
50
+ const CreateCategorySchema = z.object({
51
+ slug: z.string().min(1).max(64).regex(/^[A-Z][A-Z0-9_]*$/, "slug \uB294 \uB300\uBB38\uC790/\uC22B\uC790/\uC5B8\uB354\uC2A4\uCF54\uC5B4\uB9CC"),
52
+ labelKo: z.string().min(1).max(64),
53
+ labelEn: z.string().min(1).max(64).optional(),
54
+ sortOrder: z.number().int().nonnegative().default(0),
55
+ isActive: z.boolean().default(true)
56
+ });
57
+ const UpdateCategorySchema = CreateCategorySchema.partial();
58
+ const ReorderCategorySchema = z.object({
59
+ ids: z.array(z.string().cuid()).min(1)
60
+ });
61
+ return {
62
+ CreateGallerySchema,
63
+ UpdateGallerySchema,
64
+ BatchCreateGallerySchema,
65
+ BulkUpdateSchema,
66
+ CreateCategorySchema,
67
+ UpdateCategorySchema,
68
+ ReorderCategorySchema
69
+ };
70
+ }
71
+
72
+ export {
73
+ DEFAULT_IMAGE_URL_PROTOCOLS,
74
+ DEFAULT_IMAGE_KEY_MAX_LENGTH,
75
+ DEFAULT_IMAGE_KEY_PATTERN,
76
+ createGallerySchemas
77
+ };
78
+ //# sourceMappingURL=chunk-IEXSUEDO.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/validators/index.ts"],"sourcesContent":["import { z } from \"zod\";\n\n/**\n * spec §9-1 충실 구현.\n *\n * 호스트가 `config.limits.batchMax` 와 `config.limits.captionMaxLength` 를 넘겨\n * 패키지 내부 스키마를 생성한다. categoryId 는 cuid 형식만 검증하고,\n * 실제 카테고리 존재 여부는 service 레이어가 Prisma FK 제약으로 자연스럽게 잡는다.\n *\n * 보안 강화 (security hardening):\n * - imageUrl: 프로토콜 allowlist (기본 https/http) — javascript:/data: 스킴 차단.\n * `imageUrlHosts` 지정 시 호스트 allowlist 도 적용.\n * - imageKey: 저장소 삭제 (`config.storage.deleteKeys`) 에 그대로 전달되므로\n * 경로 순회 (`..`), 선행 슬래시, 공백/제어문자를 차단하는 형식 검증을 기본 적용.\n */\n\nexport const DEFAULT_IMAGE_URL_PROTOCOLS = [\"https:\", \"http:\"] as const;\nexport const DEFAULT_IMAGE_KEY_MAX_LENGTH = 512;\n\n/** 영숫자로 시작, 영숫자/`.`/`_`/`-`/`/` 만 허용. `..` 세그먼트 및 `//` 금지. */\nexport const DEFAULT_IMAGE_KEY_PATTERN =\n /^(?!.*(?:^|\\/)\\.\\.(?:\\/|$))(?!.*\\/\\/)[A-Za-z0-9][A-Za-z0-9._\\-/]*$/;\n\nexport interface CreateGallerySchemasOptions {\n batchMax: number;\n captionMaxLength: number;\n imageUrlProtocols?: readonly string[];\n imageUrlHosts?: readonly string[];\n imageKeyPattern?: RegExp;\n}\n\nfunction buildImageUrlSchema(opts: CreateGallerySchemasOptions) {\n const protocols = new Set(\n (opts.imageUrlProtocols ?? DEFAULT_IMAGE_URL_PROTOCOLS).map((p) =>\n p.endsWith(\":\") ? p.toLowerCase() : `${p.toLowerCase()}:`,\n ),\n );\n const hosts = opts.imageUrlHosts\n ? new Set(opts.imageUrlHosts.map((h) => h.toLowerCase()))\n : null;\n\n return z\n .string()\n .max(2048)\n .url()\n .refine(\n (value) => {\n let parsed: URL;\n try {\n parsed = new URL(value);\n } catch {\n return false;\n }\n if (!protocols.has(parsed.protocol)) return false;\n if (hosts && !hosts.has(parsed.hostname.toLowerCase())) return false;\n return true;\n },\n { message: \"imageUrl 의 프로토콜 또는 호스트가 허용 목록에 없습니다\" },\n );\n}\n\nexport function createGallerySchemas(opts: CreateGallerySchemasOptions) {\n const imageKeyPattern = opts.imageKeyPattern ?? DEFAULT_IMAGE_KEY_PATTERN;\n\n const ImageUrlSchema = buildImageUrlSchema(opts);\n const ImageKeySchema = z\n .string()\n .min(1)\n .max(DEFAULT_IMAGE_KEY_MAX_LENGTH)\n .regex(imageKeyPattern, \"imageKey 형식이 올바르지 않습니다\");\n\n const CreateGallerySchema = z.object({\n imageUrl: ImageUrlSchema,\n imageKey: ImageKeySchema.optional(),\n caption: z.string().max(opts.captionMaxLength).optional(),\n categoryId: z.string().cuid(),\n sortOrder: z.number().int().nonnegative().default(0),\n featured: z.boolean().default(false),\n published: z.boolean().default(false),\n });\n\n const UpdateGallerySchema = CreateGallerySchema.partial();\n\n const BatchCreateGallerySchema = z.object({\n items: z.array(CreateGallerySchema).min(1).max(opts.batchMax),\n });\n\n const BulkUpdateSchema = z.object({\n ids: z.array(z.string().cuid()).min(1).max(opts.batchMax),\n published: z.boolean().optional(),\n featured: z.boolean().optional(),\n });\n\n const CreateCategorySchema = z.object({\n slug: z\n .string()\n .min(1)\n .max(64)\n .regex(/^[A-Z][A-Z0-9_]*$/, \"slug 는 대문자/숫자/언더스코어만\"),\n labelKo: z.string().min(1).max(64),\n labelEn: z.string().min(1).max(64).optional(),\n sortOrder: z.number().int().nonnegative().default(0),\n isActive: z.boolean().default(true),\n });\n\n const UpdateCategorySchema = CreateCategorySchema.partial();\n\n const ReorderCategorySchema = z.object({\n ids: z.array(z.string().cuid()).min(1),\n });\n\n return {\n CreateGallerySchema,\n UpdateGallerySchema,\n BatchCreateGallerySchema,\n BulkUpdateSchema,\n CreateCategorySchema,\n UpdateCategorySchema,\n ReorderCategorySchema,\n };\n}\n\nexport type GallerySchemas = ReturnType<typeof createGallerySchemas>;\n"],"mappings":"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","names":[]}
@@ -0,0 +1,78 @@
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } }// src/validators/index.ts
2
+ var _zod = require('zod');
3
+ var DEFAULT_IMAGE_URL_PROTOCOLS = ["https:", "http:"];
4
+ var DEFAULT_IMAGE_KEY_MAX_LENGTH = 512;
5
+ var DEFAULT_IMAGE_KEY_PATTERN = /^(?!.*(?:^|\/)\.\.(?:\/|$))(?!.*\/\/)[A-Za-z0-9][A-Za-z0-9._\-/]*$/;
6
+ function buildImageUrlSchema(opts) {
7
+ const protocols = new Set(
8
+ (_nullishCoalesce(opts.imageUrlProtocols, () => ( DEFAULT_IMAGE_URL_PROTOCOLS))).map(
9
+ (p) => p.endsWith(":") ? p.toLowerCase() : `${p.toLowerCase()}:`
10
+ )
11
+ );
12
+ const hosts = opts.imageUrlHosts ? new Set(opts.imageUrlHosts.map((h) => h.toLowerCase())) : null;
13
+ return _zod.z.string().max(2048).url().refine(
14
+ (value) => {
15
+ let parsed;
16
+ try {
17
+ parsed = new URL(value);
18
+ } catch (e) {
19
+ return false;
20
+ }
21
+ if (!protocols.has(parsed.protocol)) return false;
22
+ if (hosts && !hosts.has(parsed.hostname.toLowerCase())) return false;
23
+ return true;
24
+ },
25
+ { message: "imageUrl \uC758 \uD504\uB85C\uD1A0\uCF5C \uB610\uB294 \uD638\uC2A4\uD2B8\uAC00 \uD5C8\uC6A9 \uBAA9\uB85D\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4" }
26
+ );
27
+ }
28
+ function createGallerySchemas(opts) {
29
+ const imageKeyPattern = _nullishCoalesce(opts.imageKeyPattern, () => ( DEFAULT_IMAGE_KEY_PATTERN));
30
+ const ImageUrlSchema = buildImageUrlSchema(opts);
31
+ const ImageKeySchema = _zod.z.string().min(1).max(DEFAULT_IMAGE_KEY_MAX_LENGTH).regex(imageKeyPattern, "imageKey \uD615\uC2DD\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
32
+ const CreateGallerySchema = _zod.z.object({
33
+ imageUrl: ImageUrlSchema,
34
+ imageKey: ImageKeySchema.optional(),
35
+ caption: _zod.z.string().max(opts.captionMaxLength).optional(),
36
+ categoryId: _zod.z.string().cuid(),
37
+ sortOrder: _zod.z.number().int().nonnegative().default(0),
38
+ featured: _zod.z.boolean().default(false),
39
+ published: _zod.z.boolean().default(false)
40
+ });
41
+ const UpdateGallerySchema = CreateGallerySchema.partial();
42
+ const BatchCreateGallerySchema = _zod.z.object({
43
+ items: _zod.z.array(CreateGallerySchema).min(1).max(opts.batchMax)
44
+ });
45
+ const BulkUpdateSchema = _zod.z.object({
46
+ ids: _zod.z.array(_zod.z.string().cuid()).min(1).max(opts.batchMax),
47
+ published: _zod.z.boolean().optional(),
48
+ featured: _zod.z.boolean().optional()
49
+ });
50
+ const CreateCategorySchema = _zod.z.object({
51
+ slug: _zod.z.string().min(1).max(64).regex(/^[A-Z][A-Z0-9_]*$/, "slug \uB294 \uB300\uBB38\uC790/\uC22B\uC790/\uC5B8\uB354\uC2A4\uCF54\uC5B4\uB9CC"),
52
+ labelKo: _zod.z.string().min(1).max(64),
53
+ labelEn: _zod.z.string().min(1).max(64).optional(),
54
+ sortOrder: _zod.z.number().int().nonnegative().default(0),
55
+ isActive: _zod.z.boolean().default(true)
56
+ });
57
+ const UpdateCategorySchema = CreateCategorySchema.partial();
58
+ const ReorderCategorySchema = _zod.z.object({
59
+ ids: _zod.z.array(_zod.z.string().cuid()).min(1)
60
+ });
61
+ return {
62
+ CreateGallerySchema,
63
+ UpdateGallerySchema,
64
+ BatchCreateGallerySchema,
65
+ BulkUpdateSchema,
66
+ CreateCategorySchema,
67
+ UpdateCategorySchema,
68
+ ReorderCategorySchema
69
+ };
70
+ }
71
+
72
+
73
+
74
+
75
+
76
+
77
+ exports.DEFAULT_IMAGE_URL_PROTOCOLS = DEFAULT_IMAGE_URL_PROTOCOLS; exports.DEFAULT_IMAGE_KEY_MAX_LENGTH = DEFAULT_IMAGE_KEY_MAX_LENGTH; exports.DEFAULT_IMAGE_KEY_PATTERN = DEFAULT_IMAGE_KEY_PATTERN; exports.createGallerySchemas = createGallerySchemas;
78
+ //# sourceMappingURL=chunk-IYQOJJBC.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["/Users/uni4love/project/workspace/211-withwiz/node-packages/withwiz-gallery/dist/chunk-IYQOJJBC.js","../src/validators/index.ts"],"names":[],"mappings":"AAAA;ACAA,0BAAkB;AAgBX,IAAM,4BAAA,EAA8B,CAAC,QAAA,EAAU,OAAO,CAAA;AACtD,IAAM,6BAAA,EAA+B,GAAA;AAGrC,IAAM,0BAAA,EACX,oEAAA;AAUF,SAAS,mBAAA,CAAoB,IAAA,EAAmC;AAC9D,EAAA,MAAM,UAAA,EAAY,IAAI,GAAA;AAAA,IAAA,kBACnB,IAAA,CAAK,iBAAA,UAAqB,6BAAA,CAAA,CAA6B,GAAA;AAAA,MAAI,CAAC,CAAA,EAAA,GAC3D,CAAA,CAAE,QAAA,CAAS,GAAG,EAAA,EAAI,CAAA,CAAE,WAAA,CAAY,EAAA,EAAI,CAAA,EAAA;AACtC,IAAA;AACF,EAAA;AAEiB,EAAA;AAOd,EAAA;AACY,IAAA;AACL,MAAA;AACA,MAAA;AACoB,QAAA;AAChB,MAAA;AACC,QAAA;AACT,MAAA;AAC4C,MAAA;AACJ,MAAA;AACjC,MAAA;AACT,IAAA;AACW,IAAA;AACb,EAAA;AACJ;AAEwE;AACtB,EAAA;AAED,EAAA;AAIxC,EAAA;AAG8B,EAAA;AACzB,IAAA;AACwB,IAAA;AACW,IAAA;AACjB,IAAA;AACc,IAAA;AACP,IAAA;AACC,IAAA;AACrC,EAAA;AAE+C,EAAA;AAEN,EAAA;AACG,IAAA;AAC5C,EAAA;AAEiC,EAAA;AACW,IAAA;AACX,IAAA;AACD,IAAA;AAChC,EAAA;AAEqC,EAAA;AAK3B,IAAA;AACwB,IAAA;AACW,IAAA;AACF,IAAA;AACR,IAAA;AACnC,EAAA;AAE4B,EAAA;AAEU,EAAA;AACA,IAAA;AACtC,EAAA;AAEM,EAAA;AACL,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACF,EAAA;AACF;ADlDkD;AACA;AACA;AACA;AACA;AACA;AACA","file":"/Users/uni4love/project/workspace/211-withwiz/node-packages/withwiz-gallery/dist/chunk-IYQOJJBC.js","sourcesContent":[null,"import { z } from \"zod\";\n\n/**\n * spec §9-1 충실 구현.\n *\n * 호스트가 `config.limits.batchMax` 와 `config.limits.captionMaxLength` 를 넘겨\n * 패키지 내부 스키마를 생성한다. categoryId 는 cuid 형식만 검증하고,\n * 실제 카테고리 존재 여부는 service 레이어가 Prisma FK 제약으로 자연스럽게 잡는다.\n *\n * 보안 강화 (security hardening):\n * - imageUrl: 프로토콜 allowlist (기본 https/http) — javascript:/data: 스킴 차단.\n * `imageUrlHosts` 지정 시 호스트 allowlist 도 적용.\n * - imageKey: 저장소 삭제 (`config.storage.deleteKeys`) 에 그대로 전달되므로\n * 경로 순회 (`..`), 선행 슬래시, 공백/제어문자를 차단하는 형식 검증을 기본 적용.\n */\n\nexport const DEFAULT_IMAGE_URL_PROTOCOLS = [\"https:\", \"http:\"] as const;\nexport const DEFAULT_IMAGE_KEY_MAX_LENGTH = 512;\n\n/** 영숫자로 시작, 영숫자/`.`/`_`/`-`/`/` 만 허용. `..` 세그먼트 및 `//` 금지. */\nexport const DEFAULT_IMAGE_KEY_PATTERN =\n /^(?!.*(?:^|\\/)\\.\\.(?:\\/|$))(?!.*\\/\\/)[A-Za-z0-9][A-Za-z0-9._\\-/]*$/;\n\nexport interface CreateGallerySchemasOptions {\n batchMax: number;\n captionMaxLength: number;\n imageUrlProtocols?: readonly string[];\n imageUrlHosts?: readonly string[];\n imageKeyPattern?: RegExp;\n}\n\nfunction buildImageUrlSchema(opts: CreateGallerySchemasOptions) {\n const protocols = new Set(\n (opts.imageUrlProtocols ?? DEFAULT_IMAGE_URL_PROTOCOLS).map((p) =>\n p.endsWith(\":\") ? p.toLowerCase() : `${p.toLowerCase()}:`,\n ),\n );\n const hosts = opts.imageUrlHosts\n ? new Set(opts.imageUrlHosts.map((h) => h.toLowerCase()))\n : null;\n\n return z\n .string()\n .max(2048)\n .url()\n .refine(\n (value) => {\n let parsed: URL;\n try {\n parsed = new URL(value);\n } catch {\n return false;\n }\n if (!protocols.has(parsed.protocol)) return false;\n if (hosts && !hosts.has(parsed.hostname.toLowerCase())) return false;\n return true;\n },\n { message: \"imageUrl 의 프로토콜 또는 호스트가 허용 목록에 없습니다\" },\n );\n}\n\nexport function createGallerySchemas(opts: CreateGallerySchemasOptions) {\n const imageKeyPattern = opts.imageKeyPattern ?? DEFAULT_IMAGE_KEY_PATTERN;\n\n const ImageUrlSchema = buildImageUrlSchema(opts);\n const ImageKeySchema = z\n .string()\n .min(1)\n .max(DEFAULT_IMAGE_KEY_MAX_LENGTH)\n .regex(imageKeyPattern, \"imageKey 형식이 올바르지 않습니다\");\n\n const CreateGallerySchema = z.object({\n imageUrl: ImageUrlSchema,\n imageKey: ImageKeySchema.optional(),\n caption: z.string().max(opts.captionMaxLength).optional(),\n categoryId: z.string().cuid(),\n sortOrder: z.number().int().nonnegative().default(0),\n featured: z.boolean().default(false),\n published: z.boolean().default(false),\n });\n\n const UpdateGallerySchema = CreateGallerySchema.partial();\n\n const BatchCreateGallerySchema = z.object({\n items: z.array(CreateGallerySchema).min(1).max(opts.batchMax),\n });\n\n const BulkUpdateSchema = z.object({\n ids: z.array(z.string().cuid()).min(1).max(opts.batchMax),\n published: z.boolean().optional(),\n featured: z.boolean().optional(),\n });\n\n const CreateCategorySchema = z.object({\n slug: z\n .string()\n .min(1)\n .max(64)\n .regex(/^[A-Z][A-Z0-9_]*$/, \"slug 는 대문자/숫자/언더스코어만\"),\n labelKo: z.string().min(1).max(64),\n labelEn: z.string().min(1).max(64).optional(),\n sortOrder: z.number().int().nonnegative().default(0),\n isActive: z.boolean().default(true),\n });\n\n const UpdateCategorySchema = CreateCategorySchema.partial();\n\n const ReorderCategorySchema = z.object({\n ids: z.array(z.string().cuid()).min(1),\n });\n\n return {\n CreateGallerySchema,\n UpdateGallerySchema,\n BatchCreateGallerySchema,\n BulkUpdateSchema,\n CreateCategorySchema,\n UpdateCategorySchema,\n ReorderCategorySchema,\n };\n}\n\nexport type GallerySchemas = ReturnType<typeof createGallerySchemas>;\n"]}
@@ -57,6 +57,10 @@ declare function createGalleryService(config: GalleryConfig): {
57
57
  sortBy?: SortKey$1;
58
58
  }) => Promise<PaginatedResult<GalleryListItem>>;
59
59
  getById: (id: string) => Promise<GalleryDetail | null>;
60
+ getAuthorIds: (ids: string[]) => Promise<Array<{
61
+ id: string;
62
+ authorId: string;
63
+ }>>;
60
64
  create: (data: CreateGalleryInput, authorId: string) => Promise<GalleryDetail>;
61
65
  createMany: (items: CreateGalleryInput[], authorId: string) => Promise<{
62
66
  count: number;
@@ -57,6 +57,10 @@ declare function createGalleryService(config: GalleryConfig): {
57
57
  sortBy?: SortKey$1;
58
58
  }) => Promise<PaginatedResult<GalleryListItem>>;
59
59
  getById: (id: string) => Promise<GalleryDetail | null>;
60
+ getAuthorIds: (ids: string[]) => Promise<Array<{
61
+ id: string;
62
+ authorId: string;
63
+ }>>;
60
64
  create: (data: CreateGalleryInput, authorId: string) => Promise<GalleryDetail>;
61
65
  createMany: (items: CreateGalleryInput[], authorId: string) => Promise<{
62
66
  count: number;
@@ -11,7 +11,7 @@ var _chunkGFOWZGJXjs = require('../chunk-GFOWZGJX.js');
11
11
  var _chunkWVRTM6E7js = require('../chunk-WVRTM6E7.js');
12
12
 
13
13
 
14
- var _chunkILSZPLMEjs = require('../chunk-ILSZPLME.js');
14
+ var _chunkIYQOJJBCjs = require('../chunk-IYQOJJBC.js');
15
15
 
16
16
  // src/services/helpers.ts
17
17
  function buildPaginatedResult(items, page, limit, total) {
@@ -143,6 +143,13 @@ function createGalleryService(config) {
143
143
  });
144
144
  return _nullishCoalesce(row, () => ( null));
145
145
  }
146
+ async function getAuthorIds(ids) {
147
+ const rows = await galleryDelegate().findMany({
148
+ where: { id: { in: ids } },
149
+ select: { id: true, authorId: true }
150
+ });
151
+ return rows;
152
+ }
146
153
  async function create(data, authorId) {
147
154
  const row = await galleryDelegate().create({
148
155
  data: {
@@ -272,6 +279,7 @@ function createGalleryService(config) {
272
279
  listPublishedByCategory,
273
280
  listAll,
274
281
  getById,
282
+ getAuthorIds,
275
283
  create,
276
284
  createMany,
277
285
  update,
@@ -375,19 +383,46 @@ function getRouteId(ctx) {
375
383
  const id = _optionalChain([ctx, 'access', _12 => _12.params, 'optionalAccess', _13 => _13.id]);
376
384
  return typeof id === "string" && id.length > 0 ? id : null;
377
385
  }
386
+ var DEFAULT_SEARCH_MAX_LENGTH = 100;
387
+ function canManageCategories(config, ctx) {
388
+ const fn = _optionalChain([config, 'access', _14 => _14.permissions, 'optionalAccess', _15 => _15.canManageCategories]);
389
+ return fn ? fn(ctx) === true : true;
390
+ }
391
+ async function checkBulkPermission(ctx, ids, hook, loadAuthorIds) {
392
+ if (!hook) return { ok: true };
393
+ const unique = [...new Set(ids)];
394
+ const rows = await loadAuthorIds(unique);
395
+ if (rows.length !== unique.length) {
396
+ return {
397
+ ok: false,
398
+ response: jsonError("one or more gallery items not found", 404, "NotFound")
399
+ };
400
+ }
401
+ for (const row of rows) {
402
+ if (!hook(ctx, { authorId: row.authorId })) {
403
+ return { ok: false, response: jsonError("forbidden", 403, "Forbidden") };
404
+ }
405
+ }
406
+ return { ok: true };
407
+ }
378
408
  function createGalleryRoutes(config) {
379
409
  const service = createGalleryService(config);
380
410
  const categoryService = createCategoryService(config);
381
- const schemas = _chunkILSZPLMEjs.createGallerySchemas.call(void 0, {
411
+ const schemas = _chunkIYQOJJBCjs.createGallerySchemas.call(void 0, {
382
412
  batchMax: config.limits.batchMax,
383
- captionMaxLength: _nullishCoalesce(config.limits.captionMaxLength, () => ( 200))
413
+ captionMaxLength: _nullishCoalesce(config.limits.captionMaxLength, () => ( 200)),
414
+ imageUrlProtocols: _optionalChain([config, 'access', _16 => _16.validation, 'optionalAccess', _17 => _17.imageUrlProtocols]),
415
+ imageUrlHosts: _optionalChain([config, 'access', _18 => _18.validation, 'optionalAccess', _19 => _19.imageUrlHosts]),
416
+ imageKeyPattern: _optionalChain([config, 'access', _20 => _20.validation, 'optionalAccess', _21 => _21.imageKeyPattern])
384
417
  });
418
+ const searchMaxLength = _nullishCoalesce(_optionalChain([config, 'access', _22 => _22.validation, 'optionalAccess', _23 => _23.searchMaxLength]), () => ( DEFAULT_SEARCH_MAX_LENGTH));
385
419
  const collectionGET = config.apiWrapper(async (ctx) => {
386
420
  const { page, limit } = parsePagination(ctx.request);
387
421
  const { searchParams } = new URL(ctx.request.url);
388
422
  const categoryId = getSearchParam(ctx.request, "categoryId");
389
423
  const publishedRaw = getSearchParam(ctx.request, "published");
390
- const search = getSearchParam(ctx.request, "search");
424
+ const searchRaw = _optionalChain([getSearchParam, 'call', _24 => _24(ctx.request, "search"), 'optionalAccess', _25 => _25.trim, 'call', _26 => _26()]);
425
+ const search = searchRaw ? searchRaw.slice(0, searchMaxLength) : void 0;
391
426
  const sortBy = parseSortKey(
392
427
  searchParams,
393
428
  VALID_SORT_KEYS,
@@ -416,8 +451,22 @@ function createGalleryRoutes(config) {
416
451
  });
417
452
  const collectionDELETE = config.apiWrapper(async (ctx) => {
418
453
  const body = await ctx.request.json();
419
- const check = validateIds(_optionalChain([body, 'optionalAccess', _14 => _14.ids]));
454
+ const check = validateIds(_optionalChain([body, 'optionalAccess', _27 => _27.ids]));
420
455
  if (!check.valid) return check.response;
456
+ if (check.ids.length > config.limits.batchMax) {
457
+ return jsonError(
458
+ `ids exceeds batchMax (${config.limits.batchMax})`,
459
+ 400,
460
+ "InvalidIds"
461
+ );
462
+ }
463
+ const perm = await checkBulkPermission(
464
+ ctx,
465
+ check.ids,
466
+ _optionalChain([config, 'access', _28 => _28.permissions, 'optionalAccess', _29 => _29.canDelete]),
467
+ service.getAuthorIds
468
+ );
469
+ if (!perm.ok) return perm.response;
421
470
  const { count } = await service.removeMany(check.ids);
422
471
  callRevalidate(config);
423
472
  return _server.NextResponse.json({ success: true, data: { count } });
@@ -437,7 +486,7 @@ function createGalleryRoutes(config) {
437
486
  if (!validation.success) return validation.response;
438
487
  const existing = await service.getById(id);
439
488
  if (!existing) return jsonError("Gallery item not found", 404, "NotFound");
440
- if (_optionalChain([config, 'access', _15 => _15.permissions, 'optionalAccess', _16 => _16.canEdit])) {
489
+ if (_optionalChain([config, 'access', _30 => _30.permissions, 'optionalAccess', _31 => _31.canEdit])) {
441
490
  const ok = config.permissions.canEdit(ctx, { authorId: existing.authorId });
442
491
  if (!ok) return jsonError("forbidden", 403, "Forbidden");
443
492
  }
@@ -448,7 +497,7 @@ function createGalleryRoutes(config) {
448
497
  const itemDELETE = config.apiWrapper(async (ctx) => {
449
498
  const id = getRouteId(ctx);
450
499
  if (!id) return jsonError("missing route param: id", 400, "InvalidParams");
451
- if (_optionalChain([config, 'access', _17 => _17.permissions, 'optionalAccess', _18 => _18.canDelete])) {
500
+ if (_optionalChain([config, 'access', _32 => _32.permissions, 'optionalAccess', _33 => _33.canDelete])) {
452
501
  const existing = await service.getById(id);
453
502
  if (!existing) return jsonError("Gallery item not found", 404, "NotFound");
454
503
  const ok = config.permissions.canDelete(ctx, {
@@ -463,7 +512,7 @@ function createGalleryRoutes(config) {
463
512
  const publishTogglePATCH = config.apiWrapper(async (ctx) => {
464
513
  const id = getRouteId(ctx);
465
514
  if (!id) return jsonError("missing route param: id", 400, "InvalidParams");
466
- if (_optionalChain([config, 'access', _19 => _19.permissions, 'optionalAccess', _20 => _20.canEdit])) {
515
+ if (_optionalChain([config, 'access', _34 => _34.permissions, 'optionalAccess', _35 => _35.canEdit])) {
467
516
  const existing = await service.getById(id);
468
517
  if (!existing) return jsonError("Gallery item not found", 404, "NotFound");
469
518
  const ok = config.permissions.canEdit(ctx, {
@@ -500,6 +549,13 @@ function createGalleryRoutes(config) {
500
549
  const validation = validateAndParse(schemas.BulkUpdateSchema, body);
501
550
  if (!validation.success) return validation.response;
502
551
  const { ids, published, featured } = validation.data;
552
+ const perm = await checkBulkPermission(
553
+ ctx,
554
+ ids,
555
+ _optionalChain([config, 'access', _36 => _36.permissions, 'optionalAccess', _37 => _37.canEdit]),
556
+ service.getAuthorIds
557
+ );
558
+ if (!perm.ok) return perm.response;
503
559
  let count = 0;
504
560
  if (published !== void 0) {
505
561
  const r = await service.bulkUpdatePublished(ids, published);
@@ -521,6 +577,7 @@ function createGalleryRoutes(config) {
521
577
  return _server.NextResponse.json({ success: true, data: items });
522
578
  });
523
579
  const categoryCollectionPOST = config.apiWrapper(async (ctx) => {
580
+ if (!canManageCategories(config, ctx)) return jsonError("forbidden", 403, "Forbidden");
524
581
  const body = await ctx.request.json();
525
582
  const validation = validateAndParse(schemas.CreateCategorySchema, body);
526
583
  if (!validation.success) return validation.response;
@@ -538,6 +595,7 @@ function createGalleryRoutes(config) {
538
595
  const categoryItemPUT = config.apiWrapper(async (ctx) => {
539
596
  const id = getRouteId(ctx);
540
597
  if (!id) return jsonError("missing route param: id", 400, "InvalidParams");
598
+ if (!canManageCategories(config, ctx)) return jsonError("forbidden", 403, "Forbidden");
541
599
  const body = await ctx.request.json();
542
600
  const validation = validateAndParse(schemas.UpdateCategorySchema, body);
543
601
  if (!validation.success) return validation.response;
@@ -550,6 +608,7 @@ function createGalleryRoutes(config) {
550
608
  const categoryItemDELETE = config.apiWrapper(async (ctx) => {
551
609
  const id = getRouteId(ctx);
552
610
  if (!id) return jsonError("missing route param: id", 400, "InvalidParams");
611
+ if (!canManageCategories(config, ctx)) return jsonError("forbidden", 403, "Forbidden");
553
612
  try {
554
613
  await categoryService.remove(id);
555
614
  } catch (err) {