Armazenamento de arquivos em buckets compatíveis com S3 (R2, S3, MinIO) e disco local, com uploads assinados
Instalar
genpm add @core/storageO que você recebe
- Código em src/lib/storage/, 11 arquivos. (22,7 kB)
- Regras de IA em src/lib/storage/AGENTS.md, mais arquivos de regras da IDE.
- Variáveis de ambiente adicionadas ao .env.example: STORAGE_DRIVER, STORAGE_BUCKET, STORAGE_ENDPOINT, STORAGE_ACCESS_KEY_ID, STORAGE_SECRET_ACCESS_KEY, STORAGE_PUBLIC_URL, STORAGE_SECRET, STORAGE_LOCAL_DIR.
README
Este pacote não tem README.
Isto é exatamente o que sua IA lê quando trabalha em src/lib/storage. Nada mais é adicionado ao contexto dela.
@core/storage — rules for AI agents
Purpose
One StorageProvider interface to store and serve files: S3-compatible buckets (Cloudflare R2, AWS S3, MinIO, B2)
signed with SigV4 (aws4fetch, works on Node and Workers), plus local (disk) and memory drivers for development
and tests. Signed direct uploads from the browser with the exact size and content type signed. No media library,
no image processing (that is @core/media). No tables.
Map
index.ts— public API:getStorage(),setStorage(),generateKey(),assertSafeKey(),s3Storage(), types.s3.ts— S3 driver.memory.ts— local/memory drivers and signed tokens.local-routes.ts— handlers for them.adapters/hono.ts,adapters/next.ts— upload/download route, only needed withlocal/memory.
Integration
- Env for S3/R2 (default driver):
STORAGE_ENDPOINT(R2:https://<account>.r2.cloudflarestorage.com),STORAGE_BUCKET,STORAGE_ACCESS_KEY_ID,STORAGE_SECRET_ACCESS_KEY, optionalSTORAGE_REGION(defaultauto) andSTORAGE_PUBLIC_URL(CDN/custom domain serving thepublic/prefix). Local dev:STORAGE_DRIVER=local,STORAGE_SECRET(≥ 32 chars),STORAGE_PUBLIC_URL=http://localhost:3000(files go toSTORAGE_LOCAL_DIR, default.storage/: add it to.gitignore), and mount the route: Honoapp.route('/api/storage', localStorageRoutes()); Next.jsapp/api/storage/[[...key]]/route.tswithexport { localStoragePut as PUT, localStorageGet as GET } from '@/lib/storage/adapters/next'. - Bucket CORS (S3/R2): allow
PUTfrom your origin with headercontent-type. - Direct upload: server creates
const key = generateKey('public/media', file.name)andawait getStorage().presignUpload(key, { contentType, size, maxBytes: 10_000_000 }); the browser doesfetch(url, { method: 'PUT', headers, body: file }). - Verify: upload a small file, then
getStorage().head(key)returns its size.
Conventions
- Keys are generated by the server (
generateKey); never use the user's filename or path as the key. - Everything is private unless its key starts with
public/; serve private files withpresignDownloadafter an authorization check. - Store the key in your table, not the URL (URLs change with the CDN or driver).
- A presigned upload URL can be reused until it expires. If the server validates or transforms the file after upload,
presign a private key (e.g.
uploads/pending/…) and have the server copy the validated bytes to the finalpublic/key.
Don't
- Don't make the whole bucket public or proxy private files without checking permissions.
- Don't trust the client's content type for security decisions; @core/media sniffs the real type after upload.
- Don't log storage credentials or presigned URLs.
# @core/storage — rules for AI agents
## Purpose
One `StorageProvider` interface to store and serve files: S3-compatible buckets (Cloudflare R2, AWS S3, MinIO, B2)
signed with SigV4 (`aws4fetch`, works on Node and Workers), plus `local` (disk) and `memory` drivers for development
and tests. Signed direct uploads from the browser with the exact size and content type signed. No media library,
no image processing (that is @core/media). No tables.
## Map
- `index.ts` — public API: `getStorage()`, `setStorage()`, `generateKey()`, `assertSafeKey()`, `s3Storage()`, types.
- `s3.ts` — S3 driver. `memory.ts` — local/memory drivers and signed tokens. `local-routes.ts` — handlers for them.
- `adapters/hono.ts`, `adapters/next.ts` — upload/download route, only needed with `local`/`memory`.
## Integration
1. Env for S3/R2 (default driver): `STORAGE_ENDPOINT` (R2: `https://<account>.r2.cloudflarestorage.com`),
`STORAGE_BUCKET`, `STORAGE_ACCESS_KEY_ID`, `STORAGE_SECRET_ACCESS_KEY`, optional `STORAGE_REGION` (default `auto`)
and `STORAGE_PUBLIC_URL` (CDN/custom domain serving the `public/` prefix).
Local dev: `STORAGE_DRIVER=local`, `STORAGE_SECRET` (≥ 32 chars), `STORAGE_PUBLIC_URL=http://localhost:3000`
(files go to `STORAGE_LOCAL_DIR`, default `.storage/`: add it to `.gitignore`), and
mount the route: Hono `app.route('/api/storage', localStorageRoutes())`; Next.js
`app/api/storage/[[...key]]/route.ts` with `export { localStoragePut as PUT, localStorageGet as GET } from '@/lib/storage/adapters/next'`.
2. Bucket CORS (S3/R2): allow `PUT` from your origin with header `content-type`.
3. Direct upload: server creates `const key = generateKey('public/media', file.name)` and
`await getStorage().presignUpload(key, { contentType, size, maxBytes: 10_000_000 })`; the browser does
`fetch(url, { method: 'PUT', headers, body: file })`.
4. Verify: upload a small file, then `getStorage().head(key)` returns its size.
## Conventions
- Keys are generated by the server (`generateKey`); never use the user's filename or path as the key.
- Everything is private unless its key starts with `public/`; serve private files with `presignDownload` after an authorization check.
- Store the key in your table, not the URL (URLs change with the CDN or driver).
- A presigned upload URL can be reused until it expires. If the server validates or transforms the file after upload,
presign a private key (e.g. `uploads/pending/…`) and have the server copy the validated bytes to the final `public/` key.
## Don't
- Don't make the whole bucket public or proxy private files without checking permissions.
- Don't trust the client's content type for security decisions; @core/media sniffs the real type after upload.
- Don't log storage credentials or presigned URLs.
A árvore exata que será injetada, após o .genpmignore. Fixada em
// Driver S3 compatible (Cloudflare R2, AWS S3, MinIO, Backblaze B2) con firma SigV4 de aws4fetch. Funciona en Node y Workers.
import { AwsClient, AwsV4Signer } from 'aws4fetch';
import { assertSafeKey, isPublicKey, StorageError } from './keys.ts';
import { type PresignUploadOptions, type PutBody, type PutOptions, type StorageProvider, toBytes } from './provider.ts';
export type S3Config = {
endpoint: string;
bucket: string;
region?: string;
accessKeyId: string;
secretAccessKey: string;
/** Base pública (CDN o dominio del bucket) para claves `public/`. */
publicUrl?: string;
/** Inyectable para tests. */
fetch?: typeof fetch;
};
const encodeKey = (key: string) => key.split('/').map(encodeURIComponent).join('/');
export function s3Storage(cfg: S3Config): StorageProvider {
const region = cfg.region ?? 'auto';
const client = new AwsClient({ accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey, service: 's3', region });
const doFetch = cfg.fetch ?? fetch;
const objectUrl = (key: string) => `${cfg.endpoint.replace(/\/+$/, '')}/${cfg.bucket}/${encodeKey(assertSafeKey(key))}`;
const send = async (key: string, init: RequestInit) => {
const signed = await client.sign(objectUrl(key), init);
return doFetch(signed);
};
const presign = async (key: string, method: 'GET' | 'PUT', expiresIn: number, headers: Record<string, string> = {}) => {
const url = new URL(objectUrl(key));
url.searchParams.set('X-Amz-Expires', String(Math.min(Math.max(1, Math.floor(expiresIn)), 604_800)));
const signer = new AwsV4Signer({
url: url.toString(),
method,
headers,
accessKeyId: cfg.accessKeyId,
secretAccessKey: cfg.secretAccessKey,
service: 's3',
region,
signQuery: true,
// Firma también content-type y content-length: el navegador no puede subir otro tipo ni otro tamaño.
allHeaders: true,
});
return (await signer.sign()).url.toString();
};
const info = (key: string, res: Response) => ({
key,
size: Number(res.headers.get('content-length') ?? 0),
contentType: res.headers.get('content-type') ?? 'application/octet-stream',
});
return {
driver: 's3',
async put(key: string, body: PutBody, opts: PutOptions) {
const bytes = await toBytes(body);
const headers: Record<string, string> = { 'content-type': opts.contentType };
if (opts.cacheControl) headers['cache-control'] = opts.cacheControl;
const res = await send(key, { method: 'PUT', body: bytes as Uint8Array<ArrayBuffer>, headers });
if (!res.ok) throw new StorageError('upstream', `S3 PUT ${res.status}`);
return { key, size: bytes.byteLength, contentType: opts.contentType };
},
async get(key) {
const res = await send(key, { method: 'GET' });
if (res.status === 404) return null;
if (!res.ok || !res.body) throw new StorageError('upstream', `S3 GET ${res.status}`);
return { ...info(key, res), body: res.body };
},
async head(key) {
const res = await send(key, { method: 'HEAD' });
if (res.status === 404) return null;
if (!res.ok) throw new StorageError('upstream', `S3 HEAD ${res.status}`);
return info(key, res);
},
async delete(key) {
const res = await send(key, { method: 'DELETE' });
if (!res.ok && res.status !== 404) throw new StorageError('upstream', `S3 DELETE ${res.status}`);
},
async presignUpload(key: string, opts: PresignUploadOptions) {
checkSize(opts);
const expiresIn = opts.expiresIn ?? 600;
const headers = { 'content-type': opts.contentType, 'content-length': String(opts.size) };
const url = await presign(key, 'PUT', expiresIn, headers);
// content-length lo pone el navegador; solo hace falta enviar content-type.
return { url, method: 'PUT', headers: { 'content-type': opts.contentType }, expiresAt: new Date(Date.now() + expiresIn * 1000) };
},
presignDownload: (key, expiresIn = 300) => presign(key, 'GET', expiresIn),
publicUrl(key) {
assertSafeKey(key);
if (!isPublicKey(key) || !cfg.publicUrl) throw new StorageError('not_public', `not a public key: ${key}`);
return `${cfg.publicUrl.replace(/\/+$/, '')}/${encodeKey(key)}`;
},
};
}
export function checkSize(opts: PresignUploadOptions): void {
if (!Number.isInteger(opts.size) || opts.size <= 0 || opts.size > opts.maxBytes)
throw new StorageError('too_large', `size ${opts.size} exceeds ${opts.maxBytes} bytes`);
}
Este pacote não declara servidores MCP.
| Versão | Commit | Publicado | Análise |
|---|---|---|---|
| 1.0.1 | 5b6a9cc | há 7 horas | análise aprovada |
- genpm
- nenhum
- proposto
- O GenPM propõe o comando npm e só o executa se você disser sim.
- Usado por (1)
- @core/media ^1.0.0
- análise
- análise aprovada · 0 achados
- commit
- v1.0.1 → 5b6a9cc5574ad638d71214af234145082f01cb98 · verificado após o download
- scripts
- Nenhum. O GenPM nunca executa código de pacotes.
- licença
- MIT
- Qualidade
- 100/100
- Licença reconhecidacumprido
- AGENTS.md explica o propósitocumprido
- AGENTS.md tem passos de integraçãocumprido
- AGENTS.md lista convenções ou proibiçõescumprido
- Inclui testescumprido
- Escaneamento de segurança aprovadocumprido
- Publicado nos últimos 6 mesescumprido
- Publicador verificadocumprido
- Resumo e palavras-chavecumprido
- denúncia
- Viu algo errado?