dokkebi-guard-upload 0.1.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 +199 -0
- package/dist/GuardUpload.d.ts +27 -0
- package/dist/NsfwScanner.d.ts +30 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +453 -0
- package/dist/index.js.map +1 -0
- package/dist/security/errors.d.ts +12 -0
- package/dist/security/fileFingerprint.d.ts +2 -0
- package/dist/security/manifest.d.ts +11 -0
- package/dist/security/proofToken.d.ts +23 -0
- package/dist/security/requestProof.d.ts +12 -0
- package/dist/security/sha256.d.ts +3 -0
- package/dist/security/verifyWorker.d.ts +9 -0
- package/dist/types.d.ts +154 -0
- package/dist/utils/image.d.ts +9 -0
- package/package.json +64 -0
- package/server/guard-handler.mjs +276 -0
- package/src/worker/nsfw-worker.ts +169 -0
package/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# dokkebi-guard-upload
|
|
2
|
+
|
|
3
|
+
브라우저에서 **NSFW(성인/노출) 이미지를 사전 검열**한 뒤 업로드하는 범용 라이브러리입니다.
|
|
4
|
+
|
|
5
|
+
- 모든것은 client-first 정책에 맞춰서 비용높은 서버를 사용하지 않고 보안을 강화해서
|
|
6
|
+
일반인은 변조나 건너뜀을 어렵게 하는것에 목적을둔 방식입니다.
|
|
7
|
+
|
|
8
|
+
- Web Worker + `@huggingface/transformers` (ONNX)
|
|
9
|
+
- 기본 모델: `AdamCodd/vit-base-nsfw-detector` (Transformers.js 전용, sfw/nsfw)
|
|
10
|
+
- React / Vue / Vanilla 모두 사용 가능
|
|
11
|
+
- 도깨비 프레임워크(client-side working backend framework)와 독립적으로 동작 (추후 Capability 연동 가능)
|
|
12
|
+
|
|
13
|
+
## 설치
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install dokkebi-guard-upload @huggingface/transformers
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 빠른 사용
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { createGuardUpload } from 'dokkebi-guard-upload';
|
|
23
|
+
|
|
24
|
+
const guard = createGuardUpload({
|
|
25
|
+
nsfwThreshold: 0.75, // NSFW 점수 75% 이상이면 차단
|
|
26
|
+
onLoadProgress: (p) => console.log(`모델 로드 ${Math.round(p * 100)}%`),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// 1) 검열만
|
|
30
|
+
const result = await guard.scan(file);
|
|
31
|
+
if (!result.pass) {
|
|
32
|
+
alert(result.reason);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 2) 검열 + 업로드
|
|
37
|
+
try {
|
|
38
|
+
const { url } = await guard.upload(file, {
|
|
39
|
+
endpoint: '/api/upload',
|
|
40
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
41
|
+
});
|
|
42
|
+
console.log('업로드 완료:', url);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
if (e.name === 'GuardUploadBlockedError') {
|
|
45
|
+
alert(e.message); // 검열 차단
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Worker URL (필수)
|
|
51
|
+
|
|
52
|
+
Worker는 `@huggingface/transformers`를 import하므로 **앱 번들러(Vite 등)로 worker를 빌드**해야 합니다.
|
|
53
|
+
|
|
54
|
+
### Vite (권장)
|
|
55
|
+
|
|
56
|
+
`new URL('dokkebi-guard-upload/worker', import.meta.url)` 는 alias가 `src/index.ts` 파일을 가리킬 때 **`src/index.ts/worker` 로 잘못 해석**됩니다. 아래 방식을 사용하세요.
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
import { createGuardUpload } from 'dokkebi-guard-upload';
|
|
60
|
+
import workerUrl from 'dokkebi-guard-upload/worker?worker&url';
|
|
61
|
+
|
|
62
|
+
const guard = createGuardUpload({ workerUrl });
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
로컬 소스 연결(모노레포) 시:
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
import workerUrl from '../../src/worker/nsfw-worker.ts?worker&url';
|
|
69
|
+
const guard = createGuardUpload({ workerUrl });
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`vite.config` alias (import 전용):
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
resolve: {
|
|
76
|
+
alias: {
|
|
77
|
+
'dokkebi-guard-upload': '/path/to/dokkebi-guard-upload/src/index.ts',
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 옵션
|
|
83
|
+
|
|
84
|
+
| 옵션 | 기본값 | 설명 |
|
|
85
|
+
|------|--------|------|
|
|
86
|
+
| `nsfwThreshold` | `0.75` | 차단 임계값 (0–1) |
|
|
87
|
+
| `blockLabels` | nsfw, porn, explicit… | 차단 라벨 목록 |
|
|
88
|
+
| `modelId` | `AdamCodd/vit-base-nsfw-detector` | HF 모델 (transformers.js ONNX) |
|
|
89
|
+
| `modelDtype` | `q4` | WASM 호환 dtype (`q4`→~58MB, `fp16`→~172MB) |
|
|
90
|
+
| `maxImageSide` | `512` | 추론 전 리사이즈 |
|
|
91
|
+
| `onnxWasmPaths` | *(미설정)* | Vite 번들이 wasm 제공. 정적 서버만 쓸 때 `/onnx/` 등 |
|
|
92
|
+
| `workerUrl` | 패키지 worker | Web Worker URL |
|
|
93
|
+
|
|
94
|
+
## ScanResult
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
{
|
|
98
|
+
pass: boolean; // 업로드 허용 여부
|
|
99
|
+
nsfwScore: number; // 0–1
|
|
100
|
+
labels: { label: string; score: number }[];
|
|
101
|
+
reason?: string; // 차단 사유 (한국어)
|
|
102
|
+
durationMs: number;
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## 데모 실행
|
|
107
|
+
|
|
108
|
+
| 명령 | 포트 | 설명 |
|
|
109
|
+
|------|------|------|
|
|
110
|
+
| `npm run dev:vanilla` | 5180 | Vanilla JS |
|
|
111
|
+
| `npm run dev:react` | 5181 | React 18 |
|
|
112
|
+
| `npm run dev:vue` | 5182 | Vue 3 |
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
cd dokkebi-guard-upload
|
|
116
|
+
npm install
|
|
117
|
+
npm run dev:react # 또는 dev:vue, dev:vanilla
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
첫 실행 시 Hugging Face에서 NSFW 모델을 내려받습니다 (네트워크 필요, 약 50–90MB).
|
|
121
|
+
|
|
122
|
+
> **ONNX WASM (Vite):** `onnxWasmPaths` 를 **설정하지 마세요.** Worker를 `?worker&url` 로 번들하면 Vite가 wasm asset 을 함께 제공합니다.
|
|
123
|
+
> `/onnx/` public 폴더 방식은 Vite dev 에서 `?import` 변환 오류가 납니다.
|
|
124
|
+
|
|
125
|
+
> **모델/dtype:** 기본 `q4` (WASM 호환). `model_quantized`(q8) 는 `ConvInteger` 연산으로 브라우저 WASM 에서 실패할 수 있습니다.
|
|
126
|
+
|
|
127
|
+
## 배포 보안 (Worker 무결성 + proof)
|
|
128
|
+
|
|
129
|
+
일반 사용자의 변조·우회를 어렵게 하고, 서버 audit 로그로 흔적을 남깁니다.
|
|
130
|
+
|
|
131
|
+
### 흐름
|
|
132
|
+
|
|
133
|
+
1. **빌드** → `guard-manifest.json` (worker SHA-256, buildId)
|
|
134
|
+
2. **로드** → Worker 스크립트 해시 검증 (불일치 시 차단 + `/api/guard/report`)
|
|
135
|
+
3. **검열 통과** → `/api/guard/proof` 로 HMAC 업로드 토큰 발급 (30초 TTL)
|
|
136
|
+
4. **업로드** → `X-Guard-Token` 헤더 필수 (호스트 API에서 검증)
|
|
137
|
+
|
|
138
|
+
### 사용
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
import { createGuardUpload, loadGuardManifest, securityFromManifest } from 'dokkebi-guard-upload';
|
|
142
|
+
|
|
143
|
+
const manifest = await loadGuardManifest('/guard-manifest.json');
|
|
144
|
+
const guard = createGuardUpload({
|
|
145
|
+
workerUrl: workerUrlFromVite,
|
|
146
|
+
security: securityFromManifest(manifest, {
|
|
147
|
+
proofEndpoint: '/api/guard/proof',
|
|
148
|
+
reportEndpoint: '/api/guard/report',
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await guard.upload(file, { endpoint: '/api/upload' });
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### manifest 생성
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
npm run guard:manifest # react 데모 빌드 → examples/shared/public/guard-manifest.json
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
프로덕션 빌드 시 Vite 플러그인 `guardManifestPlugin` 을 추가하면 worker 청크 해시가 자동 기록됩니다.
|
|
162
|
+
|
|
163
|
+
### 서버 (Node / CF Workers)
|
|
164
|
+
|
|
165
|
+
```javascript
|
|
166
|
+
import { createGuardHandlers, guardDevMiddleware } from 'dokkebi-guard-upload/server';
|
|
167
|
+
|
|
168
|
+
const handlers = createGuardHandlers({
|
|
169
|
+
secret: process.env.GUARD_PROOF_SECRET,
|
|
170
|
+
allowedBuilds: [{ buildId: '…', workerSha256: '…' }],
|
|
171
|
+
auditLog: (e) => myLogger.info(e),
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
환경 변수: `GUARD_PROOF_SECRET` (32+ bytes 랜덤 문자열)
|
|
176
|
+
|
|
177
|
+
### audit 이벤트 (변조 흔적)
|
|
178
|
+
|
|
179
|
+
| event | 의미 |
|
|
180
|
+
|---|---|
|
|
181
|
+
| `integrity_mismatch` | Worker 해시 불일치 |
|
|
182
|
+
| `proof_rejected` | UNKNOWN_BUILD / SCAN_BLOCKED |
|
|
183
|
+
| `proof_issued` | 정상 proof 발급 |
|
|
184
|
+
| `worker_integrity_mismatch` | 클라이언트 report |
|
|
185
|
+
| `upload_rejected` | proof 토큰 없음/만료 |
|
|
186
|
+
|
|
187
|
+
> **한계:** DevTools 숙련자·직접 API 호출은 여전히 가능합니다. proof 없는 업로드는 서버에서 반드시 거부하세요.
|
|
188
|
+
|
|
189
|
+
## 보안 주의
|
|
190
|
+
|
|
191
|
+
클라이언트 검열은 **UX·1차 필터**입니다. 위 proof·무결성 레이어와 함께 사용하면 일반인 우회 비용을 높이고 audit 로그로 흔적을 남길 수 있습니다.
|
|
192
|
+
|
|
193
|
+
*서버비 0원으로 서비스를 운영하고 싶다면 client-side backend framework
|
|
194
|
+
dokkebi-cli 도 있습니다. 사용해보세요 프론트+벡엔드를 한번에 배포하고 사용하며
|
|
195
|
+
해킹에 문제없음.
|
|
196
|
+
|
|
197
|
+
## 라이선스
|
|
198
|
+
|
|
199
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type GuardUploadInstance, type GuardUploadOptions, type ScanResult, type UploadOptions, type UploadResult } from './types';
|
|
2
|
+
export declare class GuardUpload implements GuardUploadInstance {
|
|
3
|
+
private scanner;
|
|
4
|
+
constructor(options?: GuardUploadOptions);
|
|
5
|
+
get ready(): boolean;
|
|
6
|
+
load(): Promise<void>;
|
|
7
|
+
scan(file: File | Blob): Promise<ScanResult>;
|
|
8
|
+
upload(file: File | Blob, options: UploadOptions): Promise<UploadResult>;
|
|
9
|
+
dispose(): void;
|
|
10
|
+
}
|
|
11
|
+
/** 검열 차단 — scan 결과 포함 */
|
|
12
|
+
export declare class GuardUploadBlockedError extends Error {
|
|
13
|
+
readonly scan: ScanResult;
|
|
14
|
+
constructor(message: string, scan: ScanResult);
|
|
15
|
+
}
|
|
16
|
+
/** HTTP 업로드 실패 */
|
|
17
|
+
export declare class GuardUploadHttpError extends Error {
|
|
18
|
+
readonly status: number;
|
|
19
|
+
readonly body: unknown;
|
|
20
|
+
readonly scan: ScanResult;
|
|
21
|
+
constructor(message: string, status: number, body: unknown, scan: ScanResult);
|
|
22
|
+
}
|
|
23
|
+
/** 팩토리 */
|
|
24
|
+
export declare function createGuardUpload(options?: GuardUploadOptions): GuardUploadInstance;
|
|
25
|
+
export { NsfwScanner } from './NsfwScanner';
|
|
26
|
+
export { GuardIntegrityError, GuardProofError } from './security/errors';
|
|
27
|
+
export * from './types';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type GuardSecurityConfig, type GuardUploadOptions, type ScanResult } from './types';
|
|
2
|
+
export declare class NsfwScanner {
|
|
3
|
+
private worker;
|
|
4
|
+
private loadPromise;
|
|
5
|
+
private pendingScan;
|
|
6
|
+
private _ready;
|
|
7
|
+
private integrityChecked;
|
|
8
|
+
/** 검증된 worker SHA-256 (proof 바인딩) */
|
|
9
|
+
verifiedWorkerSha256: string;
|
|
10
|
+
private readonly modelId;
|
|
11
|
+
private readonly modelDtype;
|
|
12
|
+
private readonly nsfwThreshold;
|
|
13
|
+
private readonly blockLabels;
|
|
14
|
+
private readonly maxImageSide;
|
|
15
|
+
private readonly onnxWasmPaths?;
|
|
16
|
+
private readonly workerUrl?;
|
|
17
|
+
private readonly security?;
|
|
18
|
+
private readonly onLoadProgress?;
|
|
19
|
+
private readonly onScanStart?;
|
|
20
|
+
private readonly onScanComplete?;
|
|
21
|
+
constructor(options?: GuardUploadOptions);
|
|
22
|
+
get ready(): boolean;
|
|
23
|
+
getWorkerUrl(): string | URL;
|
|
24
|
+
getSecurity(): GuardSecurityConfig | undefined;
|
|
25
|
+
load(): Promise<void>;
|
|
26
|
+
scan(file: File | Blob): Promise<ScanResult>;
|
|
27
|
+
dispose(): void;
|
|
28
|
+
private ensureIntegrity;
|
|
29
|
+
private ensureWorker;
|
|
30
|
+
}
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const x="AdamCodd/vit-base-nsfw-detector",P="q4",T=["nsfw","porn","pornography","explicit","hentai","sexy","nude","nudity"],I=.75;class L extends Error{buildId;expectedSha256;actualSha256;constructor(e,t,n,a){super(e),this.name="GuardIntegrityError",this.expectedSha256=t,this.actualSha256=n,this.buildId=a}}class g extends Error{code;scan;constructor(e,t,n){super(e),this.name="GuardProofError",this.code=t,this.scan=n}}async function U(r){const e=r instanceof Uint8Array?r:new Uint8Array(r),t=await crypto.subtle.digest("SHA-256",e);return[...new Uint8Array(t)].map(n=>n.toString(16).padStart(2,"0")).join("")}async function D(r){const t=await r.slice(0,65536).arrayBuffer(),n=`${r.size}|${r.type||"application/octet-stream"}|`,a=new TextEncoder().encode(n),o=new Uint8Array(a.length+t.byteLength);return o.set(a,0),o.set(new Uint8Array(t),a.length),U(o)}async function j(r,e,t,n){const a=await D(r),o={buildId:t.buildId,workerSha256:n,fileFingerprint:a,nsfwScore:e.nsfwScore,pass:e.pass,labels:e.labels,durationMs:e.durationMs},s=await fetch(t.proofEndpoint,{method:"POST",headers:{"Content-Type":"application/json",...t.proofHeaders},credentials:t.proofCredentials??"same-origin",body:JSON.stringify(o)}),c=await s.text();let i;try{i=JSON.parse(c)}catch{throw new g(`proof API 응답 파싱 실패 (HTTP ${s.status})`,"PROOF_PARSE_ERROR",e)}if(!s.ok){const d=typeof i.code=="string"?i.code:"PROOF_DENIED",l=typeof i.error=="string"?i.error:`proof 거부 (HTTP ${s.status})`;throw new g(l,d,e)}const u=i.token,h=i.exp;if(typeof u!="string"||typeof h!="number")throw new g("proof API 응답에 token/exp 없음","PROOF_INVALID_RESPONSE",e);return{token:u,exp:h}}async function $(r,e){if(r.reportEndpoint)try{await fetch(r.reportEndpoint,{method:"POST",headers:{"Content-Type":"application/json",...r.proofHeaders},credentials:r.proofCredentials??"same-origin",body:JSON.stringify({buildId:r.buildId,event:"worker_integrity_mismatch",...e,ts:new Date().toISOString()})})}catch{}}async function S(r){const e=typeof r=="string"?r:r.href,t=await fetch(e,{cache:"no-store"});if(!t.ok)throw new Error(`Worker 스크립트를 가져올 수 없습니다. HTTP ${t.status} (${e})`);const n=await t.arrayBuffer();return U(n)}async function O(r,e){const t=await S(r),n=e.toLowerCase();return t.toLowerCase()===n?{ok:!0,actualSha256:t}:{ok:!1,actualSha256:t,expectedSha256:n}}async function _(r,e=512){const t=await createImageBitmap(r);try{let n=t.width,a=t.height;const o=Math.min(1,e/Math.max(n,a));o<1&&(n=Math.max(1,Math.round(n*o)),a=Math.max(1,Math.round(a*o)));const s=typeof OffscreenCanvas<"u"?new OffscreenCanvas(n,a):document.createElement("canvas");s instanceof OffscreenCanvas,s.width=n,s.height=a;const c=s.getContext("2d");if(!c)throw new Error("2D canvas context unavailable");return c.drawImage(t,0,0,n,a),{pixels:c.getImageData(0,0,n,a).data,width:n,height:a}}finally{t.close?.()}}function A(r){if(r.type)return r.type.startsWith("image/");if(r instanceof File){const e=r.name.split(".").pop()?.toLowerCase()??"";return["jpg","jpeg","png","webp","gif","bmp","avif"].includes(e)}return!1}function v(r,e="upload.jpg"){return r instanceof File&&r.name?r.name:`upload.${r.type?.split("/")[1]?.replace("jpeg","jpg")??"jpg"}`}function b(){return null}class M{worker=null;loadPromise=null;pendingScan=null;_ready=!1;integrityChecked=!1;verifiedWorkerSha256="";modelId;modelDtype;nsfwThreshold;blockLabels;maxImageSide;onnxWasmPaths;workerUrl;security;onLoadProgress;onScanStart;onScanComplete;constructor(e={}){this.modelId=e.modelId??x,this.modelDtype=e.modelDtype??P,this.nsfwThreshold=e.nsfwThreshold??I,this.blockLabels=e.blockLabels??T,this.maxImageSide=e.maxImageSide??512,this.onnxWasmPaths=e.onnxWasmPaths,this.workerUrl=e.workerUrl,this.security=e.security,this.onLoadProgress=e.onLoadProgress,this.onScanStart=e.onScanStart,this.onScanComplete=e.onScanComplete}get ready(){return this._ready}getWorkerUrl(){const e=this.workerUrl??b();if(!e)throw new Error("workerUrl이 설정되지 않았습니다.");return e}getSecurity(){return this.security}async load(){if(!this._ready)return this.loadPromise?this.loadPromise:(this.loadPromise=(async()=>{await this.ensureIntegrity(),this.ensureWorker(),await new Promise((e,t)=>{const n=o=>{const s=o.data;switch(s.type){case"progress":this.onLoadProgress?.(s.progress,{status:"progress",loaded:s.loaded,total:s.total,file:s.file});break;case"ready":this._ready=!0,this.worker?.removeEventListener("message",n),e();break;case"error":this.worker?.removeEventListener("message",n),t(new Error(s.message));break}};this.worker.addEventListener("message",n),this.worker.addEventListener("error",o=>{t(new Error(o.message||"Worker error"))});const a={type:"load",data:{modelId:this.modelId,modelDtype:this.modelDtype,onnxWasmPaths:this.onnxWasmPaths,nsfwThreshold:this.nsfwThreshold,blockLabels:this.blockLabels}};this.worker.postMessage(a)})})().finally(()=>{this.loadPromise=null}),this.loadPromise)}async scan(e){if(!A(e))throw new Error("이미지 파일만 검열할 수 있습니다.");await this.load(),this.onScanStart?.();const{pixels:t,width:n,height:a}=await _(e,this.maxImageSide),o=performance.now();return new Promise((s,c)=>{if(this.pendingScan){c(new Error("이미 스캔이 진행 중입니다."));return}this.pendingScan={resolve:s,reject:c,startedAt:o},this.ensureWorker();const i=h=>{const d=h.data;if(d.type!=="result"&&d.type!=="error")return;this.worker?.removeEventListener("message",i);const l=this.pendingScan;if(this.pendingScan=null,!l)return;const y=Math.round(performance.now()-l.startedAt);if(d.type==="error"){l.reject(new Error(d.message));return}const f={pass:d.pass,nsfwScore:d.nsfwScore,labels:d.labels,reason:d.reason,durationMs:y};this.onScanComplete?.(f),l.resolve(f)};this.worker.addEventListener("message",i);const u=t.buffer.slice(0);this.worker.postMessage({type:"scan",data:{pixels:u,width:n,height:a}},[u])})}dispose(){this.pendingScan?.reject(new Error("Scanner disposed")),this.pendingScan=null,this.worker?.terminate(),this.worker=null,this._ready=!1,this.loadPromise=null,this.integrityChecked=!1,this.verifiedWorkerSha256=""}async ensureIntegrity(){if(this.integrityChecked)return;const e=this.security,t=this.getWorkerUrl(),n=await S(t);if(this.verifiedWorkerSha256=n,!(e?.verifyWorker!==!1&&!!e?.workerSha256)){this.integrityChecked=!0;return}const o=await O(t,e.workerSha256);if(!o.ok)throw await $(e,{expectedSha256:o.expectedSha256,actualSha256:o.actualSha256,workerUrl:typeof t=="string"?t:t.href}),new L("검열 모듈 무결성 검증 실패 — 변조 가능성이 있습니다.",o.expectedSha256,o.actualSha256,e.buildId);this.integrityChecked=!0}ensureWorker(){if(this.worker)return;const e=this.workerUrl??b();if(!e)throw new Error('workerUrl이 필요합니다. Vite: import workerUrl from "dokkebi-guard-upload/worker?worker&url"');this.worker=new Worker(e,{type:"module"})}}class C{scanner;constructor(e={}){this.scanner=new M(e)}get ready(){return this.scanner.ready}load(){return this.scanner.load()}scan(e){return this.scanner.scan(e)}async upload(e,t){const n=await this.scanner.scan(e);if(!n.pass)throw new W(n.reason??"업로드가 차단되었습니다.",n);const a=this.scanner.getSecurity();let o;if(a&&!t.skipProof){const p=this.scanner.verifiedWorkerSha256;if(!p)throw new g("Worker 해시 미확인","WORKER_HASH_MISSING",n);o=(await j(e,n,a,p)).token}const s=t.fieldName??"file",c=t.urlKey??"url",i=v(e),u=t.guardTokenHeader??"X-Guard-Token",h=new FormData;if(h.append(s,e,i),t.extraFields)for(const[p,E]of Object.entries(t.extraFields))h.append(p,E);const d={...t.headers??{}};o&&(d[u]=o);const l=await fetch(t.endpoint,{method:"POST",headers:d,body:h,credentials:t.credentials}),y=await l.text();let f;try{f=JSON.parse(y)}catch{throw new k(`업로드 응답을 해석할 수 없습니다. HTTP ${l.status}`,l.status,y,n)}const w=f,m=w[c];if(!l.ok||typeof m!="string"||!m){const p=typeof w.error=="string"&&w.error||typeof w.message=="string"&&w.message||`업로드 실패 (HTTP ${l.status})`;throw new k(p,l.status,f,n)}return{url:m,scan:n,raw:f,guardToken:o}}dispose(){this.scanner.dispose()}}class W extends Error{scan;constructor(e,t){super(e),this.name="GuardUploadBlockedError",this.scan=t}}class k extends Error{status;body;scan;constructor(e,t,n,a){super(e),this.name="GuardUploadHttpError",this.status=t,this.body=n,this.scan=a}}function B(r){return new C(r)}async function F(r="/guard-manifest.json"){const e=await fetch(r,{cache:"no-store"});if(!e.ok)throw new Error(`guard manifest 로드 실패: HTTP ${e.status}`);const t=await e.json();if(!t.buildId||!t.workerSha256)throw new Error("guard manifest 형식 오류 (buildId, workerSha256 필요)");return t}function H(r,e){return{buildId:r.buildId,workerSha256:r.workerSha256,proofEndpoint:e.proofEndpoint,reportEndpoint:e.reportEndpoint,verifyWorker:!0}}async function K(r,e,t){const n=await F(r);return{...t,security:H(n,e)}}function G(r){let e="";for(const t of r)e+=String.fromCharCode(t);return btoa(e).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function J(r){const e=r.length%4===0?"":"=".repeat(4-r.length%4),t=r.replace(/-/g,"+").replace(/_/g,"/")+e,n=atob(t),a=new Uint8Array(n.length);for(let o=0;o<n.length;o++)a[o]=n.charCodeAt(o);return a}async function N(r,e){const t=await crypto.subtle.importKey("raw",new TextEncoder().encode(r),{name:"HMAC",hash:"SHA-256"},!1,["sign"]),n=await crypto.subtle.sign("HMAC",t,new TextEncoder().encode(e));return G(new Uint8Array(n))}function R(r){return[r.v,r.bid,r.wh,r.fp,r.ns.toFixed(6),String(r.exp),r.nonce].join("|")}async function V(r,e){const t=G(new TextEncoder().encode(JSON.stringify(e))),n=await N(r,R(e));return`${t}.${n}`}async function Y(r,e,t=Date.now()){const n=e.lastIndexOf(".");if(n<=0)return{ok:!1,reason:"MALFORMED_TOKEN"};let a;try{const i=new TextDecoder().decode(J(e.slice(0,n)));a=JSON.parse(i)}catch{return{ok:!1,reason:"INVALID_PAYLOAD"}}if(a.v!==1)return{ok:!1,reason:"UNSUPPORTED_VERSION",payload:a};if(a.exp*1e3<t)return{ok:!1,reason:"EXPIRED",payload:a};const o=await N(r,R(a)),s=e.slice(n+1);if(o.length!==s.length)return{ok:!1,reason:"SIGNATURE_MISMATCH",payload:a};let c=0;for(let i=0;i<o.length;i++)c|=o.charCodeAt(i)^s.charCodeAt(i);return c!==0?{ok:!1,reason:"SIGNATURE_MISMATCH",payload:a}:{ok:!0,payload:a}}const q="dokkebi-guard-upload/worker?worker&url";exports.DEFAULT_BLOCK_LABELS=T;exports.DEFAULT_MODEL_DTYPE=P;exports.DEFAULT_MODEL_ID=x;exports.DEFAULT_NSFW_THRESHOLD=I;exports.GuardIntegrityError=L;exports.GuardProofError=g;exports.GuardUpload=C;exports.GuardUploadBlockedError=W;exports.GuardUploadHttpError=k;exports.NsfwScanner=M;exports.WORKER_MODULE_ID=q;exports.createGuardUpload=B;exports.createGuardUploadOptions=K;exports.fileFingerprint=D;exports.fileToPixels=_;exports.guessFilename=v;exports.hashWorkerScript=S;exports.isImageFile=A;exports.loadGuardManifest=F;exports.securityFromManifest=H;exports.signProofPayload=V;exports.verifyProofToken=Y;exports.verifyWorkerIntegrity=O;
|
|
2
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","sources":["../src/types.ts","../src/security/errors.ts","../src/security/sha256.ts","../src/security/fileFingerprint.ts","../src/security/requestProof.ts","../src/security/verifyWorker.ts","../src/utils/image.ts","../src/NsfwScanner.ts","../src/GuardUpload.ts","../src/security/manifest.ts","../src/security/proofToken.ts","../src/index.ts"],"sourcesContent":["/** 단일 라벨 점수 */\nexport interface LabelScore {\n label: string;\n score: number;\n}\n\n/** NSFW 스캔 결과 */\nexport interface ScanResult {\n /** 업로드 허용 여부 */\n pass: boolean;\n /** NSFW 관련 최고 점수 (0–1) */\n nsfwScore: number;\n /** 모델이 반환한 전체 라벨 점수 */\n labels: LabelScore[];\n /** 차단 시 사용자에게 보여줄 이유 */\n reason?: string;\n /** 처리에 걸린 시간(ms) */\n durationMs: number;\n}\n\nexport interface GuardUploadOptions {\n /** NSFW 점수가 이 값 이상이면 차단 (0–1, 기본 0.75) */\n nsfwThreshold?: number;\n /** 차단 대상 라벨 (소문자 비교). 기본: nsfw, porn, explicit, hentai, sexy */\n blockLabels?: string[];\n /** Hugging Face 모델 ID (기본 AdamCodd/vit-base-nsfw-detector — Transformers.js ONNX) */\n modelId?: string;\n /** ONNX dtype: q4 | fp16 | fp32 (기본 q4, WASM 호환) */\n modelDtype?: 'q4' | 'q4f16' | 'fp16' | 'fp32' | 'q8';\n /** 추론 전 이미지 최대 변 (기본 512) */\n maxImageSide?: number;\n /** ONNX WASM 디렉터리. 미설정 시 Vite 등 번들러가 worker와 함께 wasm을 제공 (권장) */\n onnxWasmPaths?: string;\n /** Web Worker URL. 미지정 시 패키지 worker 또는 blob worker */\n workerUrl?: string | URL;\n /** 모델 로드 진행 콜백 */\n onLoadProgress?: (progress: number, detail?: LoadProgressDetail) => void;\n /** 스캔 시작/완료 콜백 */\n onScanStart?: () => void;\n onScanComplete?: (result: ScanResult) => void;\n /** 배포용 무결성·proof (서버 검증) */\n security?: GuardSecurityConfig;\n}\n\n/** 빌드 manifest (`guard-manifest.json`) */\nexport interface GuardManifest {\n buildId: string;\n workerSha256: string;\n libVersion: string;\n builtAt: string;\n}\n\n/** 서버 proof·무결성 검증 설정 */\nexport interface GuardSecurityConfig {\n /** 빌드 ID (manifest.buildId) */\n buildId: string;\n /** Worker 스크립트 SHA-256 hex (manifest.workerSha256) */\n workerSha256: string;\n /** proof 발급 API (POST) */\n proofEndpoint: string;\n /** 변조 리포트 API (POST, 선택) */\n reportEndpoint?: string;\n /** proof 요청 추가 헤더 */\n proofHeaders?: Record<string, string>;\n proofCredentials?: RequestCredentials;\n /** Worker 해시 검증 (기본 true) */\n verifyWorker?: boolean;\n}\n\nexport interface GuardSecurityOptions extends GuardUploadOptions {\n security: GuardSecurityConfig;\n}\n\nexport interface LoadProgressDetail {\n status?: string;\n loaded?: number;\n total?: number;\n file?: string;\n}\n\nexport interface UploadOptions {\n /** multipart 업로드 URL */\n endpoint: string;\n /** FormData 필드명 (기본 'file') */\n fieldName?: string;\n /** 추가 FormData 필드 */\n extraFields?: Record<string, string | Blob>;\n /** fetch 헤더 (Authorization 등) */\n headers?: Record<string, string>;\n /** proof 토큰 헤더명 (security 사용 시 기본 X-Guard-Token) */\n guardTokenHeader?: string;\n /** 응답 JSON에서 URL을 꺼낼 키 (기본 'url') */\n urlKey?: string;\n /** fetch credentials */\n credentials?: RequestCredentials;\n /** security.proofEndpoint 를 upload 에서도 쓸 경우 false 로 개별 지정 */\n skipProof?: boolean;\n}\n\nexport interface UploadResult {\n url: string;\n scan: ScanResult;\n raw: unknown;\n /** security 사용 시 발급된 proof 토큰 */\n guardToken?: string;\n}\n\nexport interface GuardUploadInstance {\n /** 모델 로드 (최초 scan/upload 전 1회) */\n load(): Promise<void>;\n /** 이미지만 검열 */\n scan(file: File | Blob): Promise<ScanResult>;\n /** 검열 통과 시에만 업로드 */\n upload(file: File | Blob, options: UploadOptions): Promise<UploadResult>;\n /** Worker 종료 */\n dispose(): void;\n /** 모델 로드 완료 여부 */\n readonly ready: boolean;\n}\n\n/** Worker ↔ Main 메시지 타입 */\nexport type WorkerOutMessage =\n | { type: 'loading'; device?: string }\n | { type: 'progress'; progress: number; loaded?: number; total?: number; file?: string }\n | { type: 'ready'; device?: string }\n | { type: 'result'; labels: LabelScore[]; nsfwScore: number; pass: boolean; reason?: string }\n | { type: 'error'; message: string };\n\nexport type WorkerInMessage =\n | { type: 'load'; data: { modelId: string; modelDtype: string; onnxWasmPaths?: string; nsfwThreshold: number; blockLabels: string[] } }\n | { type: 'scan'; data: { pixels: ArrayBuffer; width: number; height: number } };\n\nexport const DEFAULT_MODEL_ID = 'AdamCodd/vit-base-nsfw-detector';\n\nexport const DEFAULT_MODEL_DTYPE = 'q4' as const;\n\nexport const DEFAULT_BLOCK_LABELS = [\n 'nsfw',\n 'porn',\n 'pornography',\n 'explicit',\n 'hentai',\n 'sexy',\n 'nude',\n 'nudity',\n];\n\nexport const DEFAULT_NSFW_THRESHOLD = 0.75;\n","import type { ScanResult } from '../types';\n\nexport class GuardIntegrityError extends Error {\n readonly buildId?: string;\n readonly expectedSha256: string;\n readonly actualSha256: string;\n\n constructor(message: string, expectedSha256: string, actualSha256: string, buildId?: string) {\n super(message);\n this.name = 'GuardIntegrityError';\n this.expectedSha256 = expectedSha256;\n this.actualSha256 = actualSha256;\n this.buildId = buildId;\n }\n}\n\nexport class GuardProofError extends Error {\n readonly code: string;\n readonly scan?: ScanResult;\n\n constructor(message: string, code: string, scan?: ScanResult) {\n super(message);\n this.name = 'GuardProofError';\n this.code = code;\n this.scan = scan;\n }\n}\n","/** ArrayBuffer → SHA-256 hex (브라우저 Web Crypto) */\nexport async function sha256Hex(data: ArrayBuffer | Uint8Array): Promise<string> {\n const buf = data instanceof Uint8Array ? data : new Uint8Array(data);\n const digest = await crypto.subtle.digest('SHA-256', buf as BufferSource);\n return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');\n}\n\nexport async function sha256HexFromString(text: string): Promise<string> {\n return sha256Hex(new TextEncoder().encode(text));\n}\n","import { sha256Hex } from './sha256';\n\n/** 업로드 파일 바인딩용 fingerprint (크기·타입·앞 64KB) */\nexport async function fileFingerprint(file: Blob): Promise<string> {\n const head = file.slice(0, 65536);\n const headBuf = await head.arrayBuffer();\n const meta = `${file.size}|${file.type || 'application/octet-stream'}|`;\n const metaBytes = new TextEncoder().encode(meta);\n const combined = new Uint8Array(metaBytes.length + headBuf.byteLength);\n combined.set(metaBytes, 0);\n combined.set(new Uint8Array(headBuf), metaBytes.length);\n return sha256Hex(combined);\n}\n","import type { ScanResult } from '../types';\nimport type { GuardSecurityConfig } from '../types';\nimport { fileFingerprint } from './fileFingerprint';\nimport { GuardProofError } from './errors';\n\nexport interface ProofResponse {\n token: string;\n exp: number;\n}\n\nexport async function requestUploadProof(\n file: Blob,\n scan: ScanResult,\n security: GuardSecurityConfig,\n workerSha256: string,\n): Promise<ProofResponse> {\n const fp = await fileFingerprint(file);\n const body = {\n buildId: security.buildId,\n workerSha256,\n fileFingerprint: fp,\n nsfwScore: scan.nsfwScore,\n pass: scan.pass,\n labels: scan.labels,\n durationMs: scan.durationMs,\n };\n\n const res = await fetch(security.proofEndpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n ...security.proofHeaders,\n },\n credentials: security.proofCredentials ?? 'same-origin',\n body: JSON.stringify(body),\n });\n\n const rawText = await res.text();\n let json: Record<string, unknown>;\n try {\n json = JSON.parse(rawText) as Record<string, unknown>;\n } catch {\n throw new GuardProofError(\n `proof API 응답 파싱 실패 (HTTP ${res.status})`,\n 'PROOF_PARSE_ERROR',\n scan,\n );\n }\n\n if (!res.ok) {\n const code = typeof json.code === 'string' ? json.code : 'PROOF_DENIED';\n const err = typeof json.error === 'string' ? json.error : `proof 거부 (HTTP ${res.status})`;\n throw new GuardProofError(err, code, scan);\n }\n\n const token = json.token;\n const exp = json.exp;\n if (typeof token !== 'string' || typeof exp !== 'number') {\n throw new GuardProofError('proof API 응답에 token/exp 없음', 'PROOF_INVALID_RESPONSE', scan);\n }\n\n return { token, exp };\n}\n\nexport async function reportIntegrityViolation(\n security: GuardSecurityConfig,\n detail: {\n expectedSha256: string;\n actualSha256: string;\n workerUrl: string;\n },\n): Promise<void> {\n if (!security.reportEndpoint) return;\n try {\n await fetch(security.reportEndpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...security.proofHeaders },\n credentials: security.proofCredentials ?? 'same-origin',\n body: JSON.stringify({\n buildId: security.buildId,\n event: 'worker_integrity_mismatch',\n ...detail,\n ts: new Date().toISOString(),\n }),\n });\n } catch {\n /* 리포트 실패는 업로드 차단 사유가 아님 */\n }\n}\n","import { sha256Hex } from './sha256';\n\nexport async function hashWorkerScript(workerUrl: string | URL): Promise<string> {\n const url = typeof workerUrl === 'string' ? workerUrl : workerUrl.href;\n const res = await fetch(url, { cache: 'no-store' });\n if (!res.ok) {\n throw new Error(`Worker 스크립트를 가져올 수 없습니다. HTTP ${res.status} (${url})`);\n }\n const buf = await res.arrayBuffer();\n return sha256Hex(buf);\n}\n\nexport async function verifyWorkerIntegrity(\n workerUrl: string | URL,\n expectedSha256: string,\n): Promise<{ ok: true; actualSha256: string } | { ok: false; actualSha256: string; expectedSha256: string }> {\n const actualSha256 = await hashWorkerScript(workerUrl);\n const expected = expectedSha256.toLowerCase();\n const actual = actualSha256.toLowerCase();\n if (actual === expected) return { ok: true, actualSha256 };\n return { ok: false, actualSha256, expectedSha256: expected };\n}\n","/** File/Blob → RGBA 픽셀 (최대 변 리사이즈) */\n\nexport interface ImagePixels {\n pixels: Uint8ClampedArray;\n width: number;\n height: number;\n}\n\nexport async function fileToPixels(\n file: File | Blob,\n maxSide = 512,\n): Promise<ImagePixels> {\n const bitmap = await createImageBitmap(file);\n try {\n let w = bitmap.width;\n let h = bitmap.height;\n const scale = Math.min(1, maxSide / Math.max(w, h));\n if (scale < 1) {\n w = Math.max(1, Math.round(w * scale));\n h = Math.max(1, Math.round(h * scale));\n }\n\n const canvas = typeof OffscreenCanvas !== 'undefined'\n ? new OffscreenCanvas(w, h)\n : document.createElement('canvas');\n if (!(canvas instanceof OffscreenCanvas)) {\n canvas.width = w;\n canvas.height = h;\n } else {\n (canvas as OffscreenCanvas).width = w;\n (canvas as OffscreenCanvas).height = h;\n }\n\n const ctx = canvas.getContext('2d') as CanvasRenderingContext2D | null;\n if (!ctx) throw new Error('2D canvas context unavailable');\n\n ctx.drawImage(bitmap, 0, 0, w, h);\n const imageData = ctx.getImageData(0, 0, w, h);\n return { pixels: imageData.data, width: w, height: h };\n } finally {\n bitmap.close?.();\n }\n}\n\nexport function isImageFile(file: File | Blob): boolean {\n if (file.type) return file.type.startsWith('image/');\n if (file instanceof File) {\n const ext = file.name.split('.').pop()?.toLowerCase() ?? '';\n return ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'avif'].includes(ext);\n }\n return false;\n}\n\nexport function guessFilename(file: File | Blob, fallback = 'upload.jpg'): string {\n if (file instanceof File && file.name) return file.name;\n const ext = file.type?.split('/')[1]?.replace('jpeg', 'jpg') ?? 'jpg';\n return `upload.${ext}`;\n}\n","import {\n DEFAULT_BLOCK_LABELS,\n DEFAULT_MODEL_DTYPE,\n DEFAULT_MODEL_ID,\n DEFAULT_NSFW_THRESHOLD,\n type GuardSecurityConfig,\n type GuardUploadOptions,\n type ScanResult,\n type WorkerInMessage,\n type WorkerOutMessage,\n} from './types';\nimport { GuardIntegrityError } from './security/errors';\nimport { reportIntegrityViolation } from './security/requestProof';\nimport { hashWorkerScript, verifyWorkerIntegrity } from './security/verifyWorker';\nimport { fileToPixels, isImageFile } from './utils/image';\n\nfunction defaultWorkerUrl(): URL | null {\n return null;\n}\n\ntype Pending = {\n resolve: (value: ScanResult) => void;\n reject: (reason: Error) => void;\n startedAt: number;\n};\n\nexport class NsfwScanner {\n private worker: Worker | null = null;\n private loadPromise: Promise<void> | null = null;\n private pendingScan: Pending | null = null;\n private _ready = false;\n private integrityChecked = false;\n /** 검증된 worker SHA-256 (proof 바인딩) */\n verifiedWorkerSha256 = '';\n\n private readonly modelId: string;\n private readonly modelDtype: string;\n private readonly nsfwThreshold: number;\n private readonly blockLabels: string[];\n private readonly maxImageSide: number;\n private readonly onnxWasmPaths?: string;\n private readonly workerUrl?: string | URL;\n private readonly security?: GuardSecurityConfig;\n private readonly onLoadProgress?: GuardUploadOptions['onLoadProgress'];\n private readonly onScanStart?: GuardUploadOptions['onScanStart'];\n private readonly onScanComplete?: GuardUploadOptions['onScanComplete'];\n\n constructor(options: GuardUploadOptions = {}) {\n this.modelId = options.modelId ?? DEFAULT_MODEL_ID;\n this.modelDtype = options.modelDtype ?? DEFAULT_MODEL_DTYPE;\n this.nsfwThreshold = options.nsfwThreshold ?? DEFAULT_NSFW_THRESHOLD;\n this.blockLabels = options.blockLabels ?? DEFAULT_BLOCK_LABELS;\n this.maxImageSide = options.maxImageSide ?? 512;\n this.onnxWasmPaths = options.onnxWasmPaths;\n this.workerUrl = options.workerUrl;\n this.security = options.security;\n this.onLoadProgress = options.onLoadProgress;\n this.onScanStart = options.onScanStart;\n this.onScanComplete = options.onScanComplete;\n }\n\n get ready(): boolean {\n return this._ready;\n }\n\n getWorkerUrl(): string | URL {\n const url = this.workerUrl ?? defaultWorkerUrl();\n if (!url) throw new Error('workerUrl이 설정되지 않았습니다.');\n return url;\n }\n\n getSecurity(): GuardSecurityConfig | undefined {\n return this.security;\n }\n\n async load(): Promise<void> {\n if (this._ready) return;\n if (this.loadPromise) return this.loadPromise;\n\n this.loadPromise = (async () => {\n await this.ensureIntegrity();\n this.ensureWorker();\n\n await new Promise<void>((resolve, reject) => {\n const onMessage = (ev: MessageEvent<WorkerOutMessage>) => {\n const msg = ev.data;\n switch (msg.type) {\n case 'progress':\n this.onLoadProgress?.(msg.progress, {\n status: 'progress',\n loaded: msg.loaded,\n total: msg.total,\n file: msg.file,\n });\n break;\n case 'ready':\n this._ready = true;\n this.worker?.removeEventListener('message', onMessage);\n resolve();\n break;\n case 'error':\n this.worker?.removeEventListener('message', onMessage);\n reject(new Error(msg.message));\n break;\n default:\n break;\n }\n };\n\n this.worker!.addEventListener('message', onMessage);\n this.worker!.addEventListener('error', (e) => {\n reject(new Error(e.message || 'Worker error'));\n });\n\n const payload: WorkerInMessage = {\n type: 'load',\n data: {\n modelId: this.modelId,\n modelDtype: this.modelDtype,\n onnxWasmPaths: this.onnxWasmPaths,\n nsfwThreshold: this.nsfwThreshold,\n blockLabels: this.blockLabels,\n },\n };\n this.worker!.postMessage(payload);\n });\n })().finally(() => {\n this.loadPromise = null;\n });\n\n return this.loadPromise;\n }\n\n async scan(file: File | Blob): Promise<ScanResult> {\n if (!isImageFile(file)) {\n throw new Error('이미지 파일만 검열할 수 있습니다.');\n }\n\n await this.load();\n this.onScanStart?.();\n\n const { pixels, width, height } = await fileToPixels(file, this.maxImageSide);\n const startedAt = performance.now();\n\n return new Promise<ScanResult>((resolve, reject) => {\n if (this.pendingScan) {\n reject(new Error('이미 스캔이 진행 중입니다.'));\n return;\n }\n\n this.pendingScan = { resolve, reject, startedAt };\n this.ensureWorker();\n\n const onMessage = (ev: MessageEvent<WorkerOutMessage>) => {\n const msg = ev.data;\n if (msg.type !== 'result' && msg.type !== 'error') return;\n\n this.worker?.removeEventListener('message', onMessage);\n const pending = this.pendingScan;\n this.pendingScan = null;\n if (!pending) return;\n\n const durationMs = Math.round(performance.now() - pending.startedAt);\n\n if (msg.type === 'error') {\n pending.reject(new Error(msg.message));\n return;\n }\n\n const result: ScanResult = {\n pass: msg.pass,\n nsfwScore: msg.nsfwScore,\n labels: msg.labels,\n reason: msg.reason,\n durationMs,\n };\n this.onScanComplete?.(result);\n pending.resolve(result);\n };\n\n this.worker!.addEventListener('message', onMessage);\n\n const buf = pixels.buffer.slice(0) as ArrayBuffer;\n this.worker!.postMessage(\n {\n type: 'scan',\n data: { pixels: buf, width, height },\n } satisfies WorkerInMessage,\n [buf],\n );\n });\n }\n\n dispose(): void {\n this.pendingScan?.reject(new Error('Scanner disposed'));\n this.pendingScan = null;\n this.worker?.terminate();\n this.worker = null;\n this._ready = false;\n this.loadPromise = null;\n this.integrityChecked = false;\n this.verifiedWorkerSha256 = '';\n }\n\n private async ensureIntegrity(): Promise<void> {\n if (this.integrityChecked) return;\n\n const sec = this.security;\n const workerUrl = this.getWorkerUrl();\n const actualSha256 = await hashWorkerScript(workerUrl);\n this.verifiedWorkerSha256 = actualSha256;\n\n const verifyEnabled = sec?.verifyWorker !== false && !!sec?.workerSha256;\n if (!verifyEnabled) {\n this.integrityChecked = true;\n return;\n }\n\n const check = await verifyWorkerIntegrity(workerUrl, sec!.workerSha256);\n if (!check.ok) {\n await reportIntegrityViolation(sec!, {\n expectedSha256: check.expectedSha256,\n actualSha256: check.actualSha256,\n workerUrl: typeof workerUrl === 'string' ? workerUrl : workerUrl.href,\n });\n throw new GuardIntegrityError(\n '검열 모듈 무결성 검증 실패 — 변조 가능성이 있습니다.',\n check.expectedSha256,\n check.actualSha256,\n sec!.buildId,\n );\n }\n\n this.integrityChecked = true;\n }\n\n private ensureWorker(): void {\n if (this.worker) return;\n const url = this.workerUrl ?? defaultWorkerUrl();\n if (!url) {\n throw new Error(\n 'workerUrl이 필요합니다. Vite: import workerUrl from \"dokkebi-guard-upload/worker?worker&url\"',\n );\n }\n this.worker = new Worker(url, { type: 'module' });\n }\n}\n","import { NsfwScanner } from './NsfwScanner';\nimport { GuardIntegrityError, GuardProofError } from './security/errors';\nimport { requestUploadProof } from './security/requestProof';\nimport {\n type GuardUploadInstance,\n type GuardUploadOptions,\n type ScanResult,\n type UploadOptions,\n type UploadResult,\n} from './types';\nimport { guessFilename } from './utils/image';\n\nexport class GuardUpload implements GuardUploadInstance {\n private scanner: NsfwScanner;\n\n constructor(options: GuardUploadOptions = {}) {\n this.scanner = new NsfwScanner(options);\n }\n\n get ready(): boolean {\n return this.scanner.ready;\n }\n\n load(): Promise<void> {\n return this.scanner.load();\n }\n\n scan(file: File | Blob): Promise<ScanResult> {\n return this.scanner.scan(file);\n }\n\n async upload(file: File | Blob, options: UploadOptions): Promise<UploadResult> {\n const scan = await this.scanner.scan(file);\n if (!scan.pass) {\n throw new GuardUploadBlockedError(scan.reason ?? '업로드가 차단되었습니다.', scan);\n }\n\n const sec = this.scanner.getSecurity();\n let guardToken: string | undefined;\n\n if (sec && !options.skipProof) {\n const workerSha256 = this.scanner.verifiedWorkerSha256;\n if (!workerSha256) {\n throw new GuardProofError('Worker 해시 미확인', 'WORKER_HASH_MISSING', scan);\n }\n const proof = await requestUploadProof(file, scan, sec, workerSha256);\n guardToken = proof.token;\n }\n\n const fieldName = options.fieldName ?? 'file';\n const urlKey = options.urlKey ?? 'url';\n const filename = guessFilename(file);\n const tokenHeader = options.guardTokenHeader ?? 'X-Guard-Token';\n\n const fd = new FormData();\n fd.append(fieldName, file, filename);\n if (options.extraFields) {\n for (const [k, v] of Object.entries(options.extraFields)) {\n fd.append(k, v);\n }\n }\n\n const headers: Record<string, string> = { ...(options.headers ?? {}) };\n if (guardToken) headers[tokenHeader] = guardToken;\n\n const res = await fetch(options.endpoint, {\n method: 'POST',\n headers,\n body: fd,\n credentials: options.credentials,\n });\n\n const rawText = await res.text();\n let raw: unknown;\n try {\n raw = JSON.parse(rawText);\n } catch {\n throw new GuardUploadHttpError(\n `업로드 응답을 해석할 수 없습니다. HTTP ${res.status}`,\n res.status,\n rawText,\n scan,\n );\n }\n\n const body = raw as Record<string, unknown>;\n const url = body[urlKey];\n if (!res.ok || typeof url !== 'string' || !url) {\n const errMsg =\n (typeof body.error === 'string' && body.error) ||\n (typeof body.message === 'string' && body.message) ||\n `업로드 실패 (HTTP ${res.status})`;\n throw new GuardUploadHttpError(errMsg, res.status, raw, scan);\n }\n\n return { url, scan, raw, guardToken };\n }\n\n dispose(): void {\n this.scanner.dispose();\n }\n}\n\n/** 검열 차단 — scan 결과 포함 */\nexport class GuardUploadBlockedError extends Error {\n readonly scan: ScanResult;\n\n constructor(message: string, scan: ScanResult) {\n super(message);\n this.name = 'GuardUploadBlockedError';\n this.scan = scan;\n }\n}\n\n/** HTTP 업로드 실패 */\nexport class GuardUploadHttpError extends Error {\n readonly status: number;\n readonly body: unknown;\n readonly scan: ScanResult;\n\n constructor(message: string, status: number, body: unknown, scan: ScanResult) {\n super(message);\n this.name = 'GuardUploadHttpError';\n this.status = status;\n this.body = body;\n this.scan = scan;\n }\n}\n\n/** 팩토리 */\nexport function createGuardUpload(options?: GuardUploadOptions): GuardUploadInstance {\n return new GuardUpload(options);\n}\n\nexport { NsfwScanner } from './NsfwScanner';\nexport { GuardIntegrityError, GuardProofError } from './security/errors';\nexport * from './types';\n","import type { GuardManifest, GuardSecurityConfig, GuardUploadOptions } from '../types';\n\n/** `/guard-manifest.json` 로드 */\nexport async function loadGuardManifest(url = '/guard-manifest.json'): Promise<GuardManifest> {\n const res = await fetch(url, { cache: 'no-store' });\n if (!res.ok) throw new Error(`guard manifest 로드 실패: HTTP ${res.status}`);\n const json = (await res.json()) as GuardManifest;\n if (!json.buildId || !json.workerSha256) {\n throw new Error('guard manifest 형식 오류 (buildId, workerSha256 필요)');\n }\n return json;\n}\n\nexport function securityFromManifest(\n manifest: GuardManifest,\n endpoints: { proofEndpoint: string; reportEndpoint?: string },\n): GuardSecurityConfig {\n return {\n buildId: manifest.buildId,\n workerSha256: manifest.workerSha256,\n proofEndpoint: endpoints.proofEndpoint,\n reportEndpoint: endpoints.reportEndpoint,\n verifyWorker: true,\n };\n}\n\nexport async function createGuardUploadOptions(\n manifestUrl: string,\n endpoints: { proofEndpoint: string; reportEndpoint?: string },\n base?: GuardUploadOptions,\n): Promise<GuardUploadOptions> {\n const manifest = await loadGuardManifest(manifestUrl);\n return {\n ...base,\n security: securityFromManifest(manifest, endpoints),\n };\n}\n","/**\n * 업로드 proof 토큰 — 서버·클라이언트 공용 (HMAC-SHA256)\n * 형식: base64url(JSON payload).base64url(signature)\n */\n\nexport interface GuardProofPayload {\n v: 1;\n bid: string;\n wh: string;\n fp: string;\n ns: number;\n exp: number;\n nonce: string;\n}\n\nfunction b64urlEncode(bytes: Uint8Array): string {\n let bin = '';\n for (const b of bytes) bin += String.fromCharCode(b);\n return btoa(bin).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n}\n\nfunction b64urlDecode(s: string): Uint8Array {\n const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));\n const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad;\n const bin = atob(b64);\n const out = new Uint8Array(bin.length);\n for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);\n return out;\n}\n\nasync function hmacSign(secret: string, message: string): Promise<string> {\n const key = await crypto.subtle.importKey(\n 'raw',\n new TextEncoder().encode(secret),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n );\n const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));\n return b64urlEncode(new Uint8Array(sig));\n}\n\nexport function canonicalProofMessage(p: GuardProofPayload): string {\n return [p.v, p.bid, p.wh, p.fp, p.ns.toFixed(6), String(p.exp), p.nonce].join('|');\n}\n\nexport async function signProofPayload(\n secret: string,\n payload: GuardProofPayload,\n): Promise<string> {\n const body = b64urlEncode(new TextEncoder().encode(JSON.stringify(payload)));\n const sig = await hmacSign(secret, canonicalProofMessage(payload));\n return `${body}.${sig}`;\n}\n\nexport async function verifyProofToken(\n secret: string,\n token: string,\n nowMs = Date.now(),\n): Promise<{ ok: true; payload: GuardProofPayload } | { ok: false; reason: string; payload?: GuardProofPayload }> {\n const dot = token.lastIndexOf('.');\n if (dot <= 0) return { ok: false, reason: 'MALFORMED_TOKEN' };\n\n let payload: GuardProofPayload;\n try {\n const json = new TextDecoder().decode(b64urlDecode(token.slice(0, dot)));\n payload = JSON.parse(json) as GuardProofPayload;\n } catch {\n return { ok: false, reason: 'INVALID_PAYLOAD' };\n }\n\n if (payload.v !== 1) return { ok: false, reason: 'UNSUPPORTED_VERSION', payload };\n if (payload.exp * 1000 < nowMs) return { ok: false, reason: 'EXPIRED', payload };\n\n const expectedSig = await hmacSign(secret, canonicalProofMessage(payload));\n const actualSig = token.slice(dot + 1);\n if (expectedSig.length !== actualSig.length) {\n return { ok: false, reason: 'SIGNATURE_MISMATCH', payload };\n }\n let diff = 0;\n for (let i = 0; i < expectedSig.length; i++) {\n diff |= expectedSig.charCodeAt(i) ^ actualSig.charCodeAt(i);\n }\n if (diff !== 0) return { ok: false, reason: 'SIGNATURE_MISMATCH', payload };\n\n return { ok: true, payload };\n}\n","export {\n createGuardUpload,\n GuardUpload,\n GuardUploadBlockedError,\n GuardUploadHttpError,\n GuardIntegrityError,\n GuardProofError,\n NsfwScanner,\n} from './GuardUpload';\n\nexport type {\n GuardUploadInstance,\n GuardUploadOptions,\n GuardManifest,\n GuardSecurityConfig,\n LabelScore,\n LoadProgressDetail,\n ScanResult,\n UploadOptions,\n UploadResult,\n} from './types';\n\nexport {\n DEFAULT_BLOCK_LABELS,\n DEFAULT_MODEL_DTYPE,\n DEFAULT_MODEL_ID,\n DEFAULT_NSFW_THRESHOLD,\n} from './types';\n\nexport { fileToPixels, guessFilename, isImageFile } from './utils/image';\n\nexport { loadGuardManifest, securityFromManifest, createGuardUploadOptions } from './security/manifest';\nexport { verifyProofToken, signProofPayload } from './security/proofToken';\nexport type { GuardProofPayload } from './security/proofToken';\nexport { hashWorkerScript, verifyWorkerIntegrity } from './security/verifyWorker';\nexport { fileFingerprint } from './security/fileFingerprint';\n\n/** Vite `?worker&url` import 시 모듈 경로 */\nexport const WORKER_MODULE_ID = 'dokkebi-guard-upload/worker?worker&url';\n"],"names":["DEFAULT_MODEL_ID","DEFAULT_MODEL_DTYPE","DEFAULT_BLOCK_LABELS","DEFAULT_NSFW_THRESHOLD","GuardIntegrityError","message","expectedSha256","actualSha256","buildId","GuardProofError","code","scan","sha256Hex","data","buf","digest","b","fileFingerprint","file","headBuf","meta","metaBytes","combined","requestUploadProof","security","workerSha256","fp","body","res","rawText","json","err","token","exp","reportIntegrityViolation","detail","hashWorkerScript","workerUrl","url","verifyWorkerIntegrity","expected","fileToPixels","maxSide","bitmap","w","h","scale","canvas","ctx","isImageFile","ext","guessFilename","fallback","defaultWorkerUrl","NsfwScanner","options","resolve","reject","onMessage","ev","msg","e","payload","pixels","width","height","startedAt","pending","durationMs","result","sec","check","GuardUpload","GuardUploadBlockedError","guardToken","fieldName","urlKey","filename","tokenHeader","fd","k","v","headers","raw","GuardUploadHttpError","errMsg","status","createGuardUpload","loadGuardManifest","securityFromManifest","manifest","endpoints","createGuardUploadOptions","manifestUrl","base","b64urlEncode","bytes","bin","b64urlDecode","s","pad","b64","out","i","hmacSign","secret","key","sig","canonicalProofMessage","p","signProofPayload","verifyProofToken","nowMs","dot","expectedSig","actualSig","diff","WORKER_MODULE_ID"],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { createGuardUpload, GuardUpload, GuardUploadBlockedError, GuardUploadHttpError, GuardIntegrityError, GuardProofError, NsfwScanner, } from './GuardUpload';
|
|
2
|
+
export type { GuardUploadInstance, GuardUploadOptions, GuardManifest, GuardSecurityConfig, LabelScore, LoadProgressDetail, ScanResult, UploadOptions, UploadResult, } from './types';
|
|
3
|
+
export { DEFAULT_BLOCK_LABELS, DEFAULT_MODEL_DTYPE, DEFAULT_MODEL_ID, DEFAULT_NSFW_THRESHOLD, } from './types';
|
|
4
|
+
export { fileToPixels, guessFilename, isImageFile } from './utils/image';
|
|
5
|
+
export { loadGuardManifest, securityFromManifest, createGuardUploadOptions } from './security/manifest';
|
|
6
|
+
export { verifyProofToken, signProofPayload } from './security/proofToken';
|
|
7
|
+
export type { GuardProofPayload } from './security/proofToken';
|
|
8
|
+
export { hashWorkerScript, verifyWorkerIntegrity } from './security/verifyWorker';
|
|
9
|
+
export { fileFingerprint } from './security/fileFingerprint';
|
|
10
|
+
/** Vite `?worker&url` import 시 모듈 경로 */
|
|
11
|
+
export declare const WORKER_MODULE_ID = "dokkebi-guard-upload/worker?worker&url";
|