미디어 라이브러리: 서명된 직접 업로드, 실제 파일 형식 확인, EXIF/GPS 제거, 대체 텍스트, 초점, srcset
설치
genpm add @core/media포함 내용
- src/lib/media/에 소스 코드, 파일 13개. (32.5kB)
- src/lib/media/AGENTS.md에 AI 규칙, 그리고 IDE 규칙 파일.
- .env.example에 추가되는 환경 변수: MEDIA_TRANSFORM.
- @core/contracts, @core/db, @core/storage을(를) 자동으로 해결합니다.
README
이 패키지에는 README가 없습니다.
이것이 AI가 src/lib/media에서 작업할 때 읽는 내용 그대로입니다. 그 외에는 컨텍스트에 아무것도 추가되지 않습니다.
@core/media — rules for AI agents
Purpose
Media library on top of @core/storage: the browser uploads directly with a signed URL to a private staging key
(uploads/pending/<id>.<ext>), then confirmUpload checks the real file type by its bytes (declared type must match),
removes EXIF/GPS/XMP from JPEG, PNG and WebP without re-encoding, writes the validated bytes to the final public/
key, deletes the staging copy and stores dimensions. Malformed images (chunk lengths past the end) are rejected. Alt text and focal point per file, responsive URLs (Cloudflare Images or Next.js
image optimizer) and ready <img> attributes. SVG and HTML are never accepted. Table: media.
Map
index.ts— public API:createUpload,confirmUpload,getMedia,updateMedia,deleteMedia,imageUrl,imgAttrs,mediaAdminResource.remote.ts—importRemoteImage(url, { allowedHosts })for server-side imports (https, host allowlist, size limit).library.ts— upload flow, allowed types andstagingKey(media).sniff.ts,strip.ts— byte checks.urls.ts— URLs and<img>attributes.adapters/hono.ts—mediaRoutes({ authorize }).adapters/next.ts—createUploadRoute,confirmUploadRoute.
Integration
- Configure @core/storage first (bucket CORS must allow
PUTfrom the site). Recommended: a bucket lifecycle rule that deletesuploads/pending/objects after 1 day (abandoned or replayed uploads). OptionalMEDIA_TRANSFORM:cloudflare(Images transformations enabled on the zone),vercel(Next.js image optimizer; add the storage host toimages.remotePatterns) ornone. - Migrations as in
src/lib/db/AGENTS.md. - Mount the upload routes with an
authorize(req)that returns{ userId }only for users withmedia:create(@core/rbac). - Upload from the browser:
POST /api/media/uploadswith{ filename, mime, size }→PUTthe file toupload.urlwithupload.headers→POST /api/media/uploads/<id>/confirm. Store the mediaidin your content. - Render:
const m = await getMedia(id); <img {...imgAttrs(m, { sizes: '(min-width: 768px) 50vw, 100vw' })} />. - Add
mediaAdminResourcetosrc/genpm/admin.ts. - Verify: upload a photo with GPS data; after confirm, the stored file has no EXIF and the row has width/height.
Conventions
- Reference media by
idin content; resolve URLs at render time (driver or CDN can change). - Every non-decorative image needs
alt; the admin can filtermissingAlt=true. - Hero/LCP images:
imgAttrs(m, { priority: true }); everything else stays lazy.
Don't
- Don't accept files without
confirmUpload; pending or rejected media must never be shown. - Don't presign uploads to the final
public/key: a signed PUT can be replayed until it expires and would swap a validated file for an unchecked one (servedimmutable). Upload tostagingKey(); onlyconfirmUploadwritespublic/. - Don't allow SVG uploads or serve user files from the site's own origin without the storage route's sandbox headers.
- Don't hotlink third-party images; copy them into storage first.
# @core/media — rules for AI agents
## Purpose
Media library on top of @core/storage: the browser uploads directly with a signed URL to a private staging key
(`uploads/pending/<id>.<ext>`), then `confirmUpload` checks the real file type by its bytes (declared type must match),
removes EXIF/GPS/XMP from JPEG, PNG and WebP without re-encoding, writes the validated bytes to the final `public/`
key, deletes the staging copy and stores dimensions. Malformed images (chunk lengths past the end) are rejected. Alt text and focal point per file, responsive URLs (Cloudflare Images or Next.js
image optimizer) and ready `<img>` attributes. SVG and HTML are never accepted. Table: `media`.
## Map
- `index.ts` — public API: `createUpload`, `confirmUpload`, `getMedia`, `updateMedia`, `deleteMedia`, `imageUrl`, `imgAttrs`, `mediaAdminResource`.
- `remote.ts` — `importRemoteImage(url, { allowedHosts })` for server-side imports (https, host allowlist, size limit).
- `library.ts` — upload flow, allowed types and `stagingKey(media)`. `sniff.ts`, `strip.ts` — byte checks. `urls.ts` — URLs and `<img>` attributes.
- `adapters/hono.ts` — `mediaRoutes({ authorize })`. `adapters/next.ts` — `createUploadRoute`, `confirmUploadRoute`.
## Integration
1. Configure @core/storage first (bucket CORS must allow `PUT` from the site). Recommended: a bucket lifecycle rule
that deletes `uploads/pending/` objects after 1 day (abandoned or replayed uploads). Optional `MEDIA_TRANSFORM`:
`cloudflare` (Images transformations enabled on the zone), `vercel` (Next.js image optimizer; add the storage host to `images.remotePatterns`) or `none`.
2. Migrations as in `src/lib/db/AGENTS.md`.
3. Mount the upload routes with an `authorize(req)` that returns `{ userId }` only for users with `media:create` (@core/rbac).
4. Upload from the browser: `POST /api/media/uploads` with `{ filename, mime, size }` → `PUT` the file to `upload.url`
with `upload.headers` → `POST /api/media/uploads/<id>/confirm`. Store the media `id` in your content.
5. Render: `const m = await getMedia(id); <img {...imgAttrs(m, { sizes: '(min-width: 768px) 50vw, 100vw' })} />`.
6. Add `mediaAdminResource` to `src/genpm/admin.ts`.
7. Verify: upload a photo with GPS data; after confirm, the stored file has no EXIF and the row has width/height.
## Conventions
- Reference media by `id` in content; resolve URLs at render time (driver or CDN can change).
- Every non-decorative image needs `alt`; the admin can filter `missingAlt=true`.
- Hero/LCP images: `imgAttrs(m, { priority: true })`; everything else stays lazy.
## Don't
- Don't accept files without `confirmUpload`; pending or rejected media must never be shown.
- Don't presign uploads to the final `public/` key: a signed PUT can be replayed until it expires and would swap a
validated file for an unchecked one (served `immutable`). Upload to `stagingKey()`; only `confirmUpload` writes `public/`.
- Don't allow SVG uploads or serve user files from the site's own origin without the storage route's sandbox headers.
- Don't hotlink third-party images; copy them into storage first.
.genpmignore 적용 후 주입될 정확한 트리입니다. 고정 대상:
// Importa una imagen desde una URL remota a la biblioteca (para importaciones de proveedores o migraciones).
// Protección SSRF: solo https, solo hosts de la lista permitida (también tras redirecciones), tamaño y tiempo máximos,
// y el contenido debe ser una imagen real (se comprueba por bytes y se le quitan los metadatos).
import { type Executor, getDb, newId } from '../db/index.ts';
import { getStorage } from '../storage/index.ts';
import { ALLOWED, MediaError } from './library.ts';
import { type Media, media } from './schema.ts';
import { imageSize, sniff } from './sniff.ts';
import { MalformedImageError, stripMetadata } from './strip.ts';
export type RemoteImageOptions = {
/** Hosts exactos permitidos (`ae01.alicdn.com`) o sufijos con punto inicial (`.alicdn.com`). */
allowedHosts: string[];
maxBytes?: number;
alt?: string;
folder?: string;
timeoutMs?: number;
fetch?: typeof fetch;
};
const hostAllowed = (host: string, allowed: string[]) =>
allowed.some((a) => (a.startsWith('.') ? host.endsWith(a) && host.length > a.length : host === a));
export async function importRemoteImage(url: string, opts: RemoteImageOptions, db: Executor = getDb()): Promise<Media> {
const doFetch = opts.fetch ?? fetch;
const maxBytes = Math.min(opts.maxBytes ?? 10 * 1024 * 1024, 15 * 1024 * 1024);
const signal = AbortSignal.timeout(opts.timeoutMs ?? 15_000);
let current = url;
let res: Response | null = null;
for (let hop = 0; hop < 4; hop++) {
let u: URL;
try {
u = new URL(current);
} catch {
throw new MediaError('invalid_input', 'invalid image URL');
}
if (u.protocol !== 'https:' || u.username || u.password || !hostAllowed(u.hostname.toLowerCase(), opts.allowedHosts))
throw new MediaError('invalid_input', `image host not allowed: ${u.hostname}`);
res = await doFetch(u.toString(), { redirect: 'manual', signal, headers: { accept: 'image/*' } });
if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
current = new URL(res.headers.get('location')!, u).toString();
continue;
}
break;
}
if (!res || !res.ok || !res.body) throw new MediaError('invalid_input', `image download failed (${res?.status ?? 'no response'})`);
if (Number(res.headers.get('content-length') ?? 0) > maxBytes) throw new MediaError('too_large', 'image too large');
const chunks: Uint8Array[] = [];
let size = 0;
const reader = res.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > maxBytes) {
await reader.cancel();
throw new MediaError('too_large', 'image too large');
}
chunks.push(value);
}
let bytes = new Uint8Array(size);
let o = 0;
for (const c of chunks) {
bytes.set(c, o);
o += c.byteLength;
}
const type = sniff(bytes);
if (!type || !type.startsWith('image/')) throw new MediaError('type_mismatch', 'not an image');
try {
bytes = stripMetadata(bytes, type) as Uint8Array<ArrayBuffer>;
} catch (e) {
if (e instanceof MalformedImageError) throw new MediaError('type_mismatch', `malformed ${type}`);
throw e;
}
const dims = imageSize(bytes, type);
const id = newId('med');
const now = new Date();
const key = `public/media/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${id.slice(4).toLowerCase()}.${ALLOWED[type].ext}`;
await getStorage().put(key, bytes, { contentType: type, cacheControl: 'public, max-age=31536000, immutable' });
const filename = decodeURIComponent(new URL(current).pathname.split('/').pop() ?? '').replace(/[^\w.-]/g, '').slice(0, 200) || `image.${ALLOWED[type].ext}`;
const [row] = await db
.insert(media)
.values({ id, key, filename, mime: type, size: bytes.length, width: dims?.width ?? null, height: dims?.height ?? null, alt: (opts.alt ?? '').slice(0, 500), folder: opts.folder ?? '', status: 'ready' })
.returning();
return row!;
}
이 패키지는 MCP 서버를 선언하지 않습니다.
| 버전 | 커밋 | 게시일 | 검사 |
|---|---|---|---|
| 1.1.0 | 868490c | 6시간 전 | 검사 통과 |
- npm
- zod ^4.0.0
- 제안됨
- GenPM은 npm 명령을 제안하고, 동의한 경우에만 실행합니다.
- 검사
- 검사 통과 · 문제 0건
- 커밋
- v1.1.0 → 868490cea569361668f1903822e20c1becc618d4 · 가져온 뒤 검증됨
- 스크립트
- 없음. GenPM은 패키지 코드를 절대 실행하지 않습니다.
- 라이선스
- MIT
- 품질
- 100/100
- 인정된 라이선스충족
- AGENTS.md에 목적 설명충족
- AGENTS.md에 통합 단계충족
- AGENTS.md에 규칙 또는 금지 사항충족
- 테스트 포함충족
- 보안 검사 통과충족
- 최근 6개월 내 게시충족
- 인증된 게시자충족
- 요약과 키워드충족
- 신고
- 문제가 있나요?