Biblioteca de medios: subida directa firmada, tipo real comprobado, sin EXIF/GPS, texto alternativo, punto focal, srcset
Instalar
genpm add @core/mediaQué obtienes
- Código en src/lib/media/, 13 archivos. (32,5 kB)
- Reglas de IA en src/lib/media/AGENTS.md, más archivos de reglas para tu IDE.
- Variables añadidas a .env.example: MEDIA_TRANSFORM.
- Resuelve @core/contracts, @core/db, @core/storage por ti.
README
Este paquete no tiene README.
Esto es exactamente lo que lee tu IA cuando trabaja en src/lib/media. No se añade nada más a su contexto.
@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.
El árbol exacto que se inyectará, tras aplicar .genpmignore. Anclado a
// Elimina metadatos (EXIF con GPS, XMP, IPTC, comentarios) de JPEG, PNG y WebP sin recodificar la imagen.
import type { SniffedType } from './sniff.ts';
/** Archivo mal formado (un segmento declara más bytes de los que hay, o una longitud imposible). */
export class MalformedImageError extends Error {
constructor(message = 'malformed image') {
super(message);
this.name = 'MalformedImageError';
}
}
const concat = (parts: Uint8Array[]) => {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let o = 0;
for (const p of parts) {
out.set(p, o);
o += p.length;
}
return out;
};
/** JPEG: quita APP1 (EXIF/XMP), APP13 (IPTC) y COM; conserva APP0, APP2 (perfil ICC) y lo demás. */
function stripJpeg(b: Uint8Array): Uint8Array {
const parts: Uint8Array[] = [b.subarray(0, 2)];
let i = 2;
while (i + 4 <= b.length && b[i] === 0xff) {
const marker = b[i + 1]!;
if (marker === 0xda) break; // inicio de datos de imagen: el resto se copia tal cual
const len = (b[i + 2]! << 8) | b[i + 3]!;
// La longitud incluye sus 2 bytes: < 2 es imposible; más allá del final, archivo truncado o manipulado.
if (len < 2 || i + 2 + len > b.length) throw new MalformedImageError('malformed JPEG segment');
if (marker !== 0xe1 && marker !== 0xed && marker !== 0xfe) parts.push(b.subarray(i, i + 2 + len));
i += 2 + len;
}
parts.push(b.subarray(i));
return concat(parts);
}
/** PNG: quita eXIf, tEXt, zTXt, iTXt y tIME. */
function stripPng(b: Uint8Array): Uint8Array {
const drop = new Set(['eXIf', 'tEXt', 'zTXt', 'iTXt', 'tIME']);
const parts: Uint8Array[] = [b.subarray(0, 8)];
let i = 8;
while (i + 12 <= b.length) {
const len = ((b[i]! << 24) >>> 0) + (b[i + 1]! << 16) + (b[i + 2]! << 8) + b[i + 3]!;
const type = String.fromCharCode(...b.subarray(i + 4, i + 8));
const end = i + 12 + len;
if (end > b.length) throw new MalformedImageError('malformed PNG chunk');
if (!drop.has(type)) parts.push(b.subarray(i, end));
i = end;
}
return concat(parts);
}
/** WebP: quita los chunks EXIF y XMP, ajusta el tamaño RIFF y las banderas de VP8X. */
function stripWebp(b: Uint8Array): Uint8Array {
const parts: Uint8Array[] = [];
let i = 12;
while (i + 8 <= b.length) {
const type = String.fromCharCode(...b.subarray(i, i + 4));
// Sin signo (`>>> 0`): con `| (x << 24)` una longitud como 0xFFFFFFF8 sería negativa y el bucle no avanzaría.
const len = (b[i + 4]! | (b[i + 5]! << 8) | (b[i + 6]! << 16) | (b[i + 7]! << 24)) >>> 0;
const dataEnd = i + 8 + len;
if (dataEnd > b.length) throw new MalformedImageError('malformed WebP chunk');
// Byte de relleno de los chunks impares; se tolera que falte en el último.
const end = Math.min(dataEnd + (len % 2), b.length);
if (type !== 'EXIF' && type !== 'XMP ') {
const chunk = b.slice(i, end);
if (type === 'VP8X') chunk[8] = chunk[8]! & ~0x0c; // banderas EXIF (0x08) y XMP (0x04)
parts.push(chunk);
}
i = end;
}
const body = concat(parts);
const header = b.slice(0, 12);
const size = body.length + 4;
header.set([size & 0xff, (size >> 8) & 0xff, (size >> 16) & 0xff, (size >>> 24) & 0xff], 4);
return concat([header, body]);
}
/** Lanza `MalformedImageError` si la estructura del archivo no es coherente (nunca entra en bucle). */
export function stripMetadata(b: Uint8Array, type: SniffedType): Uint8Array {
if (type === 'image/jpeg') return stripJpeg(b);
if (type === 'image/png') return stripPng(b);
if (type === 'image/webp') return stripWebp(b);
return b;
}
Este paquete no declara servidores MCP.
| Versión | Commit | Publicado | Escaneo |
|---|---|---|---|
| 1.1.0 | 868490c | hace 7 horas | escaneo superado |
- npm
- zod ^4.0.0
- propuesta
- GenPM propone el comando npm y solo lo ejecuta si dices que sí.
- escaneo
- escaneo superado · 0 hallazgos
- commit
- v1.1.0 → 868490cea569361668f1903822e20c1becc618d4 · verificado tras la descarga
- scripts
- Ninguno. GenPM nunca ejecuta código del paquete.
- licencia
- MIT
- Calidad
- 100/100
- Licencia reconocidacumplido
- AGENTS.md explica su propósitocumplido
- AGENTS.md tiene pasos de integracióncumplido
- AGENTS.md lista convenciones o prohibicionescumplido
- Incluye testscumplido
- Escaneo de seguridad superadocumplido
- Publicado en los últimos 6 mesescumplido
- Publicador verificadocumplido
- Resumen y palabras clavecumplido
- reporte
- ¿Ves algo raro?