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étodo | Responde |
|---|---|
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.uploadOmitir 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.viewNo 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 32La 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.