Dateispeicher für S3-kompatible Buckets (R2, S3, MinIO) und lokale Platte, mit signierten Direkt-Uploads
Installieren
genpm add @core/storageWas du bekommst
- Quellcode in src/lib/storage/, 11 Dateien. (22,7 kB)
- KI-Regeln in src/lib/storage/AGENTS.md, dazu Regeldateien für die IDE.
- Umgebungsvariablen in .env.example ergänzt: STORAGE_DRIVER, STORAGE_BUCKET, STORAGE_ENDPOINT, STORAGE_ACCESS_KEY_ID, STORAGE_SECRET_ACCESS_KEY, STORAGE_PUBLIC_URL, STORAGE_SECRET, STORAGE_LOCAL_DIR.
README
Dieses Paket hat keine README.
Genau das liest deine KI, wenn sie in src/lib/storage arbeitet. Sonst wird ihrem Kontext nichts hinzugefügt.
@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.
Der genaue Baum, der nach .genpmignore eingebunden wird. Gepinnt an
// 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`);
}
Dieses Paket deklariert keine MCP-Server.
| Version | Commit | Veröffentlicht | Prüfung |
|---|---|---|---|
| 1.0.1 | 5b6a9cc | vor 4 Stunden | Prüfung bestanden |
- genpm
- keine
- vorgeschlagen
- GenPM schlägt den npm-Befehl vor und führt ihn nur aus, wenn du zustimmst.
- Verwendet von (1)
- @core/media ^1.0.0
- Prüfung
- Prüfung bestanden · 0 Befunde
- Commit
- v1.0.1 → 5b6a9cc5574ad638d71214af234145082f01cb98 · nach dem Abruf verifiziert
- Skripte
- Keine. GenPM führt niemals Paketcode aus.
- Lizenz
- MIT
- Qualität
- 100/100
- Anerkannte Lizenzerfüllt
- AGENTS.md erklärt den Zweckerfüllt
- AGENTS.md enthält Integrationsschritteerfüllt
- AGENTS.md nennt Konventionen oder Verboteerfüllt
- Enthält Testserfüllt
- Sicherheitsscan bestandenerfüllt
- In den letzten 6 Monaten veröffentlichterfüllt
- Verifizierter Herausgebererfüllt
- Zusammenfassung und Schlagwörtererfüllt
- Meldung
- Stimmt etwas nicht?