メディアライブラリ:署名付き直接アップロード、実ファイル形式の検証、EXIF/GPS 除去、代替テキスト、焦点、srcset
インストール
genpm add @core/media含まれるもの
- src/lib/media/ にソースコード(13 ファイル)。 (32.5 KB)
- 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 適用後に組み込まれる正確なツリーです。固定先:
// Biblioteca de medios: subida directa firmada, confirmación con comprobación del tipo real, limpieza de metadatos.
import { eq } from 'drizzle-orm';
import { type Executor, getDb, newId } from '../db/index.ts';
import { getStorage, type PresignedUpload } from '../storage/index.ts';
import { type Media, media } from './schema.ts';
import { imageSize, type SniffedType, sniff } from './sniff.ts';
import { MalformedImageError, stripMetadata } from './strip.ts';
import { imgAttrs } from './urls.ts';
export class MediaError extends Error {
constructor(
readonly code: 'unsupported_type' | 'too_large' | 'type_mismatch' | 'not_found' | 'invalid_state' | 'invalid_input' | 'forbidden',
message: string = code,
) {
super(message);
this.name = 'MediaError';
}
}
const MB = 1024 * 1024;
/** Tipos admitidos, extensión y tamaño máximo. SVG no se admite (puede contener scripts). */
export const ALLOWED: Record<SniffedType, { ext: string; maxBytes: number }> = {
'image/jpeg': { ext: 'jpg', maxBytes: 15 * MB },
'image/png': { ext: 'png', maxBytes: 15 * MB },
'image/gif': { ext: 'gif', maxBytes: 10 * MB },
'image/webp': { ext: 'webp', maxBytes: 15 * MB },
'image/avif': { ext: 'avif', maxBytes: 15 * MB },
'application/pdf': { ext: 'pdf', maxBytes: 25 * MB },
'video/mp4': { ext: 'mp4', maxBytes: 200 * MB },
'video/webm': { ext: 'webm', maxBytes: 200 * MB },
};
const isAllowed = (mime: string): mime is SniffedType => mime in ALLOWED;
const FOLDER_RE = /^([a-z0-9][a-z0-9-]{0,40}(\/[a-z0-9][a-z0-9-]{0,40}){0,3})?$/;
/**
* Clave privada donde sube el navegador. La URL firmada puede reutilizarse hasta que caduca, así que nunca apunta a la
* clave pública final: `confirmUpload` lee de aquí, valida y limpia, y escribe él mismo los bytes en `public/`.
*/
export const stagingKey = (m: Pick<Media, 'id' | 'mime'>) => `uploads/pending/${m.id}.${isAllowed(m.mime) ? ALLOWED[m.mime].ext : 'bin'}`;
export type CreateUploadInput = { filename: string; mime: string; size: number; folder?: string; alt?: string; uploadedBy?: string | null };
/** Reserva un medio `pending` y devuelve la URL firmada para subirlo directamente desde el navegador. */
export async function createUpload(input: CreateUploadInput, db: Executor = getDb()): Promise<{ media: Media; upload: PresignedUpload }> {
if (!isAllowed(input.mime)) throw new MediaError('unsupported_type', `unsupported type: ${input.mime}`);
const { ext, maxBytes } = ALLOWED[input.mime];
if (!Number.isInteger(input.size) || input.size <= 0 || input.size > maxBytes) throw new MediaError('too_large', `max ${maxBytes} bytes for ${input.mime}`);
const folder = input.folder ?? '';
if (!FOLDER_RE.test(folder)) throw new MediaError('invalid_input', `invalid folder: ${folder}`);
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()}.${ext}`;
const filename = input.filename.replace(/[\u0000-\u001f\\/]/g, '').slice(0, 200) || `file.${ext}`;
const [row] = await db
.insert(media)
.values({ id, key, filename, mime: input.mime, size: input.size, folder, alt: (input.alt ?? '').slice(0, 500), uploadedBy: input.uploadedBy ?? null })
.returning();
const upload = await getStorage().presignUpload(stagingKey(row!), { contentType: input.mime, size: input.size, maxBytes });
return { media: row!, upload };
}
async function readAll(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
return new Uint8Array(await new Response(stream).arrayBuffer());
}
/**
* Tras la subida: lee el archivo de la clave de subida privada y comprueba que su contenido es del tipo declarado; si
* no, lo borra y lo marca `rejected`. En imágenes elimina EXIF/GPS/XMP. Solo entonces escribe los bytes validados en
* la clave pública final, guarda las dimensiones y borra la copia de subida.
*/
export async function confirmUpload(id: string, db: Executor = getDb()): Promise<Media> {
const [row] = await db.select().from(media).where(eq(media.id, id));
if (!row) throw new MediaError('not_found', `media ${id} not found`);
if (row.status !== 'pending') return row;
const storage = getStorage();
const staged = stagingKey(row);
const obj = await storage.get(staged);
if (!obj) throw new MediaError('invalid_state', 'file not uploaded yet');
let bytes = await readAll(obj.body);
const reject = async (message: string) => {
await storage.delete(staged);
await db.update(media).set({ status: 'rejected' }).where(eq(media.id, id));
return new MediaError('type_mismatch', message);
};
const type = sniff(bytes);
if (type !== row.mime) throw await reject(`content is ${type ?? 'unknown'}, declared ${row.mime}`);
if (type.startsWith('image/')) {
try {
bytes = stripMetadata(bytes, type);
} catch (e) {
if (e instanceof MalformedImageError) throw await reject(`malformed ${type}`);
throw e;
}
}
await storage.put(row.key, bytes, { contentType: type, cacheControl: 'public, max-age=31536000, immutable' });
await storage.delete(staged);
const dims = type.startsWith('image/') ? imageSize(bytes, type) : null;
const [updated] = await db
.update(media)
.set({ status: 'ready', size: bytes.length, width: dims?.width ?? null, height: dims?.height ?? null })
.where(eq(media.id, id))
.returning();
return updated!;
}
export async function getMedia(id: string, db: Executor = getDb()): Promise<Media | null> {
const [row] = await db.select().from(media).where(eq(media.id, id));
return row ?? null;
}
export async function updateMedia(
id: string,
patch: { alt?: string; focalX?: number; focalY?: number; folder?: string; filename?: string },
db: Executor = getDb(),
): Promise<Media> {
const clamp = (n: number | undefined) => (n === undefined ? undefined : Math.min(1, Math.max(0, n)));
if (patch.folder !== undefined && !FOLDER_RE.test(patch.folder)) throw new MediaError('invalid_input', `invalid folder: ${patch.folder}`);
const [row] = await db
.update(media)
.set({
...(patch.alt !== undefined && { alt: patch.alt.slice(0, 500) }),
...(patch.filename !== undefined && { filename: patch.filename.slice(0, 200) }),
...(patch.folder !== undefined && { folder: patch.folder }),
...(patch.focalX !== undefined && { focalX: clamp(patch.focalX) }),
...(patch.focalY !== undefined && { focalY: clamp(patch.focalY) }),
})
.where(eq(media.id, id))
.returning();
if (!row) throw new MediaError('not_found', `media ${id} not found`);
return row;
}
/** Borra el registro y el archivo. */
export async function deleteMedia(id: string, db: Executor = getDb()): Promise<void> {
const row = await getMedia(id, db);
if (!row) return;
await getStorage().delete(row.key);
if (row.status === 'pending') await getStorage().delete(stagingKey(row));
await db.delete(media).where(eq(media.id, id));
}
/** Imagen lista para `<img>` (src, srcSet, sizes, dimensiones y alt) o null si no existe o no está lista. */
export async function imageFor(
id: string | null | undefined,
opts: { sizes?: string; alt?: string; widths?: number[] } = {},
db: Executor = getDb(),
): Promise<{ src: string; alt: string; width?: number; height?: number; srcSet?: string; sizes?: string } | null> {
if (!id) return null;
const m = await getMedia(id, db);
if (!m || m.status !== 'ready') return null;
const a = imgAttrs(m, { ...(opts.sizes && { sizes: opts.sizes }), ...(opts.widths && { widths: opts.widths }) });
return { src: a.src, alt: opts.alt ?? a.alt, ...(a.width && { width: a.width }), ...(a.height && { height: a.height }), ...(a.srcSet && { srcSet: a.srcSet }), ...(a.sizes && { sizes: a.sizes }) };
}
このパッケージは 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 か月以内に公開達成
- 認証済みの公開者達成
- 概要とキーワード達成
- 報告
- 問題を見つけましたか?