Servidor

createStorage, qué reemplazó una subida, lecturas en streaming, los routers de Express y Next.js App Router, y cifrado en reposo.

Servidor

createStorage

El lado servidor del contrato: vuelve a correr la misma validación que corrió el navegador, cifra lo que el scope declare, y habla con un StorageProvider.

import { createStorage } from 'uploaderkit/server'
import { createGcsProvider } from 'uploaderkit/adapters/gcs'

const storage = createStorage({
	scopes,
	provider: createGcsProvider({ publicBucket, privateBucket }),
	crypto: { encrypt, decrypt }, // requerido si algún scope declara `encrypt`
	signedUrlTtl: 300, // segundos
})
MétodoResponde
upload({ scope, entityId, file, uploadedBy })Un UploadResult: el StoredFile a persistir, más replaced.
read({ scope, key })Bytes crudos, descifrados cuando el scope está cifrado.
remove({ scope, key })true cuando el objeto existía.
signedUrl({ scope, key, download, expiresIn })Una URL fresca con expiración. Lanza en scopes públicos.
list(prefix){ key, size }[].

La construcción es defensiva: un scope privado sobre un provider que no puede firmar, o un scope cifrado sin crypto, lanza un ScopeError antes de la primera petición — mientras el deploy todavía puede fallar en voz alta.

Qué reemplazó una subida

upload() responde un UploadResult — un StoredFile más las keys que el barrido eliminó:

const { key, url, replaced } = await storage.upload({ scope, entityId, file })

// El bucket ya no las tiene. Lo que persististe también debe olvidarlas, o tu
// UI sigue renderizando objetos que ya no existen.
await db.files.deleteMany({ key: { $in: replaced } })

replaced viene vacío salvo que el scope resuelva a un replace 'entity', y solo lista lo que el provider confirmó borrado. El barrido corre después de un put exitoso — un fallo entre los dos dejaría a la entidad sin nada — y un delete que falla se traga: la subida que pidió quien llama sí ocurrió, y un objeto huérfano no vale fallarla.

La url de un objeto público lleva una huella corta ?v= de su contenido, así un scope de key estable (un avatar) deja de servir la imagen anterior desde un CDN o la caché del navegador después de sobrescribir.

Lecturas en streaming

storage.readStream({ scope, key }) sirve un archivo sin sostenerlo en memoria — por get, un documento de 20MB cuesta su tamaño completo en RAM por lector concurrente. El handler view de Express lo pipea. Degrada con honestidad: un provider sin getStream, o un scope cifrado cuyo crypto no trae decryptStream, cae a la lectura bufferizada envuelta en un stream de un solo chunk — quien llama recibe siempre la misma forma.

El trade del descifrado en streaming, dicho donde decides: el plaintext llega al consumidor antes de verificar el tag GCM, así que una alteración aparece como un stream que se rompe al final — read() verifica antes de entregar un solo byte.

Express

Formas estructurales de request/response en vez de los tipos de Express, para que el paquete no cargue dependencias y cualquier app de Express 4/5 las cumpla. La app conserva la propiedad de multer:

import { createExpressStorageHandlers } from 'uploaderkit/server/express'

const handlers = createExpressStorageHandlers(storage, {
	authorize: async req => (req.user ? { userId: req.user.id } : null),
})

const upload = multer({ storage: multer.memoryStorage() })
router.post(
	'/:scope/:entityId/upload',
	useAuth,
	upload.single('file'),
	handlers.upload
)
router.delete('/:scope/:entityId', useAuth, handlers.remove)
router.get('/:scope/:entityId/signed-url', useAuth, handlers.signedUrl)

authorize devuelve el usuario que actúa (o {} para "permitido") para seguir, o null para responder 401.

Next.js App Router

La misma superficie sobre la Fetch API:

// app/api/storage/[scope]/[entityId]/upload/route.ts
import { createNextStorageHandlers } from 'uploaderkit/server/next'

const handlers = createNextStorageHandlers(storage, {
	authorize: async request => {
		const session = await auth(request)
		return session ? { userId: session.userId } : null
	},
})

export const POST = handlers.upload

Omitir authorize deja el router abierto — solo aceptable detrás de un proxy autenticado.

Cifrado

Un scope cifrado necesita dos cosas cableadas, y createStorage lanza al arrancar si falta cualquiera: el cipher y encryptedUrl.

const storage = createStorage({
	scopes,
	provider,
	crypto,
	// Dónde puede LEER un cliente un objeto cifrado. El bucket guarda
	// ciphertext, así que una URL firmada serviría basura — esto tiene que
	// apuntar a tu ruta autenticada de view, que descifra a la salida.
	encryptedUrl: ({ scope, entityId, key }) =>
		`/api/storage/${scope}/${entityId}/view?key=${encodeURIComponent(key)}`,
})

El StoredFile.url de esos scopes es esa ruta, así un <img> o el FileViewer renderizan el archivo real. Ambos adaptadores de framework exponen la ruta como handlers.view, respondiendo los bytes descifrados con Cache-Control: private, no-store — el contenido descifrado nunca debe caer en un caché compartido:

// Express
router.get('/:scope/:entityId/view', useAuth, handlers.view)

// Next App Router — app/api/storage/[scope]/[entityId]/view/route.ts
export const GET = handlers.view

No se impone ningún cipher: un scope declara encrypt: true y la app inyecta los CryptoHooks. createAesGcmCrypto es la implementación de referencia (AES-256-GCM, layout [iv 12][tag 16][ciphertext]) para que no la escribas a mano:

import { createAesGcmCrypto } from 'uploaderkit/server'

const crypto = createAesGcmCrypto(process.env.STORAGE_KEY!) // openssl rand -hex 32

La llave debe ser exactamente 64 caracteres hex (32 bytes) — sin derivación desde passphrase a propósito, porque derivar dejaría a dos instancias corriendo un secreto "casi igual" y produciendo archivos mutuamente ilegibles en silencio.

Los objetos cifrados se guardan como application/octet-stream, así nada intenta renderizar texto cifrado; read() descifra a la salida.

On this page