@miguelmorales13/nestkit 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -9
- package/dist/auth/oauth/index.cjs +75 -8
- package/dist/auth/oauth/index.js +73 -6
- package/dist/auth/oauth/oauth-auth.service.d.ts +29 -1
- package/dist/auth/oauth/oauth.options.d.ts +11 -0
- package/dist/index.cjs +4 -4
- package/dist/index.js +4 -4
- package/dist/media/index.cjs +57 -7
- package/dist/media/index.d.ts +2 -2
- package/dist/media/index.js +56 -6
- package/dist/media/media.options.d.ts +20 -0
- package/dist/media/media.service.d.ts +18 -1
- package/dist/tiktok/index.cjs +29 -6
- package/dist/tiktok/index.js +29 -6
- package/dist/tiktok/tiktok.service.d.ts +24 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -580,10 +580,8 @@ descripciones a 800, que son los límites del API — pasarse devuelve un 400.
|
|
|
580
580
|
|
|
581
581
|
### `tiktok` — publicar videos, y el modo que evita la auditoría
|
|
582
582
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
requisito de sus guías, no una cortesía: la API rechaza publicar si no se
|
|
586
|
-
consultó antes qué admite la cuenta.
|
|
583
|
+
Publicar son dos llamadas: inicializar la subida y sondear hasta que TikTok
|
|
584
|
+
termine de procesar.
|
|
587
585
|
|
|
588
586
|
```ts
|
|
589
587
|
import { TikTokModule, TikTokService } from '@miguelmorales13/nestkit/tiktok';
|
|
@@ -593,11 +591,18 @@ export class AppModule {}
|
|
|
593
591
|
```
|
|
594
592
|
|
|
595
593
|
```ts
|
|
596
|
-
await tiktok.
|
|
594
|
+
await tiktok.userInfo(); // comprueba el token sin publicar
|
|
597
595
|
const id = await tiktok.publish({ data, title }); // sin privacyLevel: a borradores
|
|
598
596
|
await tiktok.status(id); // hasta PUBLISH_COMPLETE
|
|
599
597
|
```
|
|
600
598
|
|
|
599
|
+
**Cuidado con `creatorInfo()`.** Parece el paso previo obligatorio —así lo
|
|
600
|
+
sugiere la documentación— pero solo lo es para publicar **en directo**, y
|
|
601
|
+
además exige `video.publish`. Llamarlo con un token de borradores devuelve
|
|
602
|
+
`scope_not_authorized`, o sea que falla justo en la configuración más común.
|
|
603
|
+
Para comprobar que el token vive, `userInfo()`, que se conforma con
|
|
604
|
+
`user.info.basic`.
|
|
605
|
+
|
|
601
606
|
**La diferencia entre los dos modos importa mucho al principio.** En *Direct
|
|
602
607
|
Post* el video sale en vivo, pero exige el permiso `video.publish`, que solo se
|
|
603
608
|
consigue pasando una auditoría de TikTok de varias semanas —y hasta aprobarla
|
|
@@ -852,6 +857,28 @@ van a la misma base, una sola clase implementa los dos y se atan con `useExistin
|
|
|
852
857
|
`revokeIfActive` **tiene que ser atómico** —un UPDATE condicional, no un leer-y-escribir—: esa
|
|
853
858
|
fila es lo único que impide que un refresh token robado se use dos veces.
|
|
854
859
|
|
|
860
|
+
**El `state` va incluido y no se puede apagar.** Sin él, cualquiera puede entregarle a una persona
|
|
861
|
+
una URL de callback que lleva un `code` de **su propia** cuenta del proveedor: el navegador de la
|
|
862
|
+
víctima completa el flujo y acaba dentro de la sesión del atacante, metiendo sus datos —su peso, su
|
|
863
|
+
dieta, lo que sea— en una cuenta que controla otro. Es el ataque que este parámetro existe para
|
|
864
|
+
cerrar, y es fácil no tenerlo sin notarlo, porque el login funciona igual de bien sin él.
|
|
865
|
+
|
|
866
|
+
`GET /auth/:provider` firma un `state` y lo deja en una cookie al salir; el callback exige que el
|
|
867
|
+
valor que devuelve el proveedor coincida con esa cookie, y lo comprueba **antes** de canjear el
|
|
868
|
+
`code` —si la llamada no salió de un acceso empezado en ese navegador, no hay por qué gastar el
|
|
869
|
+
`code` ni hablar con el proveedor—. La cookie se borra pase lo que pase, así que una pestaña
|
|
870
|
+
olvidada no sirve para repetir el callback más tarde.
|
|
871
|
+
|
|
872
|
+
Va firmado en vez de guardado en una tabla: no necesita esquema ni limpieza y caduca solo. Se
|
|
873
|
+
ajusta con `stateCookieName` y `stateTtlSeconds` (diez minutos por defecto: da para escribir una
|
|
874
|
+
contraseña y pasar un segundo factor, y deja sin valor a una URL filtrada para cuando alguien la
|
|
875
|
+
encuentre).
|
|
876
|
+
|
|
877
|
+
> **Incompatible desde 0.11.0.** `authorizeUrl(provider)` devolvía la URL como string y ahora
|
|
878
|
+
> devuelve `{ url, state, expiresAt }` — el `state` hay que escribirlo en una cookie. Si usás
|
|
879
|
+
> `createOAuthController()` no tenés que hacer nada, ya lo hace. Solo afecta a quien llame al
|
|
880
|
+
> servicio desde un controlador propio.
|
|
881
|
+
|
|
855
882
|
Personalizable sin tocar la librería: nombre y path de la cookie, TTLs, `sameSite`, a dónde vuelve
|
|
856
883
|
el navegador tras entrar / fallar / vincular, si un login cierra las demás sesiones
|
|
857
884
|
(`singleSession`, por defecto sí), qué campos del usuario salen por la API (`mapUser`), el guard de
|
|
@@ -980,28 +1007,52 @@ MediaModule.forRoot({
|
|
|
980
1007
|
publicUrl: 'https://api.tuapp.com/api', // ¡con el prefijo global!
|
|
981
1008
|
uploadSecret: process.env.MEDIA_SECRET,
|
|
982
1009
|
ttlMs: 60 * 60 * 1000, // por defecto una hora
|
|
1010
|
+
// Por defecto: png, jpeg, webp y gif. Ampliá solo con formatos que no
|
|
1011
|
+
// puedan ejecutar nada (ver abajo por qué SVG no está en la lista).
|
|
1012
|
+
allowedContentTypes: ['image/png', 'image/jpeg'],
|
|
1013
|
+
limits: { maxFiles: 50, maxTotalBytes: 256 * 1024 * 1024 }, // los de por defecto
|
|
983
1014
|
})
|
|
984
1015
|
```
|
|
985
1016
|
|
|
986
1017
|
Expone `POST /media` (devuelve `{ url }`) y `GET /media/:id`. La subida va protegida con un secreto
|
|
987
1018
|
compartido en la cabecera `x-media-secret`, no con un JWT, porque quien llama es un job de CI y no
|
|
988
|
-
una persona con sesión; sin él cualquiera podría alojar contenido arbitrario en tu dominio.
|
|
1019
|
+
una persona con sesión; sin él cualquiera podría alojar contenido arbitrario en tu dominio. Se
|
|
1020
|
+
compara en tiempo constante: un `!==` corta en el primer byte distinto, así que lo que tarda
|
|
1021
|
+
delata cuánto se acertó. La
|
|
989
1022
|
descarga es pública a propósito: los servicios que consumen estas URLs las piden sin credenciales.
|
|
990
1023
|
|
|
991
1024
|
**Vive en memoria, a propósito.** Entre guardar y publicar pasan segundos, así que un reinicio en el
|
|
992
1025
|
medio solo significa reintentar, y no queda nada en disco. Si necesitás que los archivos sobrevivan
|
|
993
1026
|
a un reinicio, esta es la herramienta equivocada: usá `storage`.
|
|
994
1027
|
|
|
1028
|
+
Y por vivir en memoria, **tiene techo**: tope de archivos y de bytes, porque esa memoria es el
|
|
1029
|
+
montículo del proceso y las entradas duran una hora. Sin límite, un bucle contra el endpoint de
|
|
1030
|
+
subida —tanto un fallo de un job de publicación como alguien con el secreto— lo hace crecer hasta
|
|
1031
|
+
que el proceso muere, y se lleva por delante la API entera, no solo las subidas. Al llegar al tope
|
|
1032
|
+
**rechaza en vez de desalojar lo más viejo**: desalojar rompería en silencio una publicación en
|
|
1033
|
+
curso, y quien la lanzó no puede saber que su imagen desapareció hasta que el servicio remoto
|
|
1034
|
+
responde un 404 que no se explica.
|
|
1035
|
+
|
|
1036
|
+
**No acepta SVG, y no es un olvido.** Un SVG es un documento que puede llevar script, y servido
|
|
1037
|
+
desde tu dominio ese script corre **en el origen de tu aplicación**: sus cookies, su
|
|
1038
|
+
almacenamiento. Es un XSS almacenado que regala el propio alojador de archivos. Por eso el tipo
|
|
1039
|
+
declarado se coteja contra una lista en vez de tomarse por bueno, y al servir se manda
|
|
1040
|
+
`x-content-type-options: nosniff` para que el navegador tampoco decida por su cuenta.
|
|
1041
|
+
|
|
995
1042
|
Dos errores que cuestan una tarde:
|
|
996
1043
|
|
|
997
1044
|
- `publicUrl` **tiene que incluir el prefijo global** de la app. Sin él la URL cae en lo que sirva el
|
|
998
1045
|
frontend, que responde 200 con una página HTML, y el consumidor se descarga eso en vez del archivo.
|
|
999
1046
|
- Fastify solo parsea JSON de fábrica. Sin registrar un parser para los tipos que vas a aceptar, la
|
|
1000
|
-
subida responde 415
|
|
1047
|
+
subida responde 415. **Listá los tipos, no uses `/^image\//`** —que es lo que decía este
|
|
1048
|
+
ejemplo hasta 0.11.0—: ese patrón deja pasar `image/svg+xml`, y aunque el módulo ahora lo
|
|
1049
|
+
rechace, es mejor que ni siquiera llegue a parsearse:
|
|
1001
1050
|
|
|
1002
1051
|
```ts
|
|
1003
1052
|
app.getHttpAdapter().getInstance().addContentTypeParser(
|
|
1004
|
-
|
|
1053
|
+
['image/png', 'image/jpeg', 'image/webp'],
|
|
1054
|
+
{ parseAs: 'buffer', bodyLimit: 12 * 1024 * 1024 },
|
|
1055
|
+
(_req, body, done) => done(null, body),
|
|
1005
1056
|
)
|
|
1006
1057
|
```
|
|
1007
1058
|
|
|
@@ -1072,9 +1123,13 @@ trae los datos.
|
|
|
1072
1123
|
| `@miguelmorales13/nestkit/i18n` | `I18nModule` (wrapper de `nestjs-i18n`), `translateOr` |
|
|
1073
1124
|
| `@miguelmorales13/nestkit/bootstrap` | `applyNestKitDefaults` |
|
|
1074
1125
|
|
|
1126
|
+
## Cambios recientes
|
|
1127
|
+
|
|
1128
|
+
Qué se añadió en cada versión y por qué está hecho así: [CAMBIOS.md](./CAMBIOS.md).
|
|
1129
|
+
|
|
1075
1130
|
## Estado del paquete
|
|
1076
1131
|
|
|
1077
|
-
`0.
|
|
1132
|
+
`0.11.0`. Sin adaptador Mongo real. Sin tests unitarios propios.
|
|
1078
1133
|
|
|
1079
1134
|
`auth/oauth` y `umami` sí tienen un consumidor real: **nutrimx** corre su acceso con Google y
|
|
1080
1135
|
Facebook sobre este módulo en producción. El resto sigue sin ejercitarse de verdad.
|
|
@@ -141,9 +141,11 @@ function resolveOAuthOptions(options) {
|
|
|
141
141
|
secure: _nullishCoalesce(_optionalChain([options, 'access', _9 => _9.cookie, 'optionalAccess', _10 => _10.secure]), () => ( process.env.NODE_ENV === "production"))
|
|
142
142
|
},
|
|
143
143
|
linkCookieName: _nullishCoalesce(options.linkCookieName, () => ( "link_intent")),
|
|
144
|
+
stateCookieName: _nullishCoalesce(options.stateCookieName, () => ( "oauth_state")),
|
|
144
145
|
accessTtlSeconds: _nullishCoalesce(options.accessTtlSeconds, () => ( 15 * 60)),
|
|
145
146
|
refreshTtlSeconds: _nullishCoalesce(options.refreshTtlSeconds, () => ( 30 * 24 * 60 * 60)),
|
|
146
147
|
linkTtlSeconds: _nullishCoalesce(options.linkTtlSeconds, () => ( 10 * 60)),
|
|
148
|
+
stateTtlSeconds: _nullishCoalesce(options.stateTtlSeconds, () => ( 10 * 60)),
|
|
147
149
|
accessSecret: options.accessSecret,
|
|
148
150
|
refreshSecret: options.refreshSecret,
|
|
149
151
|
singleSession: _nullishCoalesce(options.singleSession, () => ( true)),
|
|
@@ -192,8 +194,60 @@ var OAuthAuthService = class {
|
|
|
192
194
|
}
|
|
193
195
|
return found;
|
|
194
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* The provider's consent URL, together with the `state` that has to travel
|
|
199
|
+
* back with the callback.
|
|
200
|
+
*
|
|
201
|
+
* `state` is not decoration. Without it, anyone can hand a victim a
|
|
202
|
+
* callback URL carrying an authorization code for *their own* provider
|
|
203
|
+
* account: the victim's browser completes the flow and ends up signed in as
|
|
204
|
+
* the attacker, entering their data into an account someone else controls.
|
|
205
|
+
* The value goes out in the URL and, signed, into a cookie; the callback
|
|
206
|
+
* proceeds only when the two match, which a third party cannot arrange
|
|
207
|
+
* because they cannot write cookies for this domain.
|
|
208
|
+
*
|
|
209
|
+
* Signed rather than stored in a table: it needs no schema and no cleanup,
|
|
210
|
+
* and it expires on its own.
|
|
211
|
+
*/
|
|
195
212
|
authorizeUrl(name) {
|
|
196
|
-
|
|
213
|
+
const provider = this.provider(name);
|
|
214
|
+
const ttl = this.options.stateTtlSeconds;
|
|
215
|
+
const state = _jsonwebtoken2.default.sign(
|
|
216
|
+
// `nonce` makes two authorize requests within the same second produce
|
|
217
|
+
// different tokens; `typ` keeps this from being swapped with the link
|
|
218
|
+
// token, which is signed with the same secret.
|
|
219
|
+
{ typ: "state", provider: provider.name, nonce: _crypto.randomUUID.call(void 0, ) },
|
|
220
|
+
requireSecret(this.options.accessSecret, "JWT_ACCESS_SECRET"),
|
|
221
|
+
{ expiresIn: ttl }
|
|
222
|
+
);
|
|
223
|
+
return {
|
|
224
|
+
url: buildAuthorizeUrl(provider, state),
|
|
225
|
+
state,
|
|
226
|
+
expiresAt: new Date(Date.now() + ttl * 1e3)
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Whether the `state` the provider sent back is the one issued for this
|
|
231
|
+
* provider, to this browser, and still valid.
|
|
232
|
+
*
|
|
233
|
+
* Both halves are checked. Matching the cookie proves the callback belongs
|
|
234
|
+
* to the authorize request this browser started; verifying the signature
|
|
235
|
+
* proves the pair was not simply invented by whoever crafted the URL.
|
|
236
|
+
*/
|
|
237
|
+
verifyState(received, fromCookie, provider) {
|
|
238
|
+
if (!received || !fromCookie) return false;
|
|
239
|
+
const a = Buffer.from(received);
|
|
240
|
+
const b = Buffer.from(fromCookie);
|
|
241
|
+
if (a.length !== b.length || !_crypto.timingSafeEqual.call(void 0, a, b)) return false;
|
|
242
|
+
try {
|
|
243
|
+
const payload = _jsonwebtoken2.default.verify(
|
|
244
|
+
received,
|
|
245
|
+
requireSecret(this.options.accessSecret, "JWT_ACCESS_SECRET")
|
|
246
|
+
);
|
|
247
|
+
return payload.typ === "state" && payload.provider === provider;
|
|
248
|
+
} catch (e2) {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
197
251
|
}
|
|
198
252
|
/** Exchanges the callback's `code` for a normalised profile. */
|
|
199
253
|
async profileFromCode(name, code) {
|
|
@@ -303,7 +357,7 @@ var OAuthAuthService = class {
|
|
|
303
357
|
requireSecret(this.options.accessSecret, "JWT_ACCESS_SECRET")
|
|
304
358
|
);
|
|
305
359
|
return payload.typ === "link" && payload.sub ? payload.sub : null;
|
|
306
|
-
} catch (
|
|
360
|
+
} catch (e3) {
|
|
307
361
|
return null;
|
|
308
362
|
}
|
|
309
363
|
}
|
|
@@ -388,7 +442,7 @@ var OAuthAuthService = class {
|
|
|
388
442
|
token,
|
|
389
443
|
requireSecret(this.options.refreshSecret, "JWT_REFRESH_SECRET")
|
|
390
444
|
);
|
|
391
|
-
} catch (
|
|
445
|
+
} catch (e4) {
|
|
392
446
|
throw new (0, _chunkFDNGAYTZcjs.UnauthorizedAppException)("Invalid refresh token.");
|
|
393
447
|
}
|
|
394
448
|
}
|
|
@@ -491,12 +545,24 @@ function createOAuthController(options = {}) {
|
|
|
491
545
|
return { ok: true };
|
|
492
546
|
}
|
|
493
547
|
start(provider, reply) {
|
|
494
|
-
|
|
548
|
+
const { url, state, expiresAt } = this.auth.authorizeUrl(provider);
|
|
549
|
+
setCookie(reply, this.auth.config.stateCookieName, state, {
|
|
550
|
+
...this.cookieBase(),
|
|
551
|
+
expires: expiresAt
|
|
552
|
+
});
|
|
553
|
+
redirect(reply, url);
|
|
495
554
|
}
|
|
496
|
-
async callback(provider, code, request, reply) {
|
|
555
|
+
async callback(provider, code, state, request, reply) {
|
|
497
556
|
if (!code) throw new (0, _chunkFDNGAYTZcjs.UnauthorizedAppException)(this.auth.config.genericErrorMessage());
|
|
557
|
+
const stateCookie = _optionalChain([request, 'access', _19 => _19.cookies, 'optionalAccess', _20 => _20[this.auth.config.stateCookieName]]);
|
|
558
|
+
clearCookie(reply, this.auth.config.stateCookieName, {
|
|
559
|
+
path: this.auth.config.cookie.path
|
|
560
|
+
});
|
|
561
|
+
if (!this.auth.verifyState(state, stateCookie, provider)) {
|
|
562
|
+
throw new (0, _chunkFDNGAYTZcjs.UnauthorizedAppException)(this.auth.config.genericErrorMessage());
|
|
563
|
+
}
|
|
498
564
|
const profile = await this.auth.profileFromCode(provider, code);
|
|
499
|
-
const linkUserId = this.auth.readLinkToken(_optionalChain([request, 'access',
|
|
565
|
+
const linkUserId = this.auth.readLinkToken(_optionalChain([request, 'access', _21 => _21.cookies, 'optionalAccess', _22 => _22[this.auth.config.linkCookieName]]));
|
|
500
566
|
if (linkUserId) {
|
|
501
567
|
clearCookie(reply, this.auth.config.linkCookieName, { path: this.auth.config.cookie.path });
|
|
502
568
|
try {
|
|
@@ -562,8 +628,9 @@ function createOAuthController(options = {}) {
|
|
|
562
628
|
_common.UseFilters.call(void 0, OAuthCallbackFilter),
|
|
563
629
|
_chunk2REOCMUDcjs.__decorateParam.call(void 0, 0, _common.Param.call(void 0, "provider")),
|
|
564
630
|
_chunk2REOCMUDcjs.__decorateParam.call(void 0, 1, _common.Query.call(void 0, "code")),
|
|
565
|
-
_chunk2REOCMUDcjs.__decorateParam.call(void 0, 2, _common.
|
|
566
|
-
_chunk2REOCMUDcjs.__decorateParam.call(void 0, 3, _common.
|
|
631
|
+
_chunk2REOCMUDcjs.__decorateParam.call(void 0, 2, _common.Query.call(void 0, "state")),
|
|
632
|
+
_chunk2REOCMUDcjs.__decorateParam.call(void 0, 3, _common.Req.call(void 0, )),
|
|
633
|
+
_chunk2REOCMUDcjs.__decorateParam.call(void 0, 4, _common.Res.call(void 0, ))
|
|
567
634
|
], OAuthControllerHost.prototype, "callback", 1);
|
|
568
635
|
OAuthControllerHost = _chunk2REOCMUDcjs.__decorateClass.call(void 0, [
|
|
569
636
|
_common.Controller.call(void 0, _nullishCoalesce(options.path, () => ( "auth")))
|
package/dist/auth/oauth/index.js
CHANGED
|
@@ -141,9 +141,11 @@ function resolveOAuthOptions(options) {
|
|
|
141
141
|
secure: options.cookie?.secure ?? process.env.NODE_ENV === "production"
|
|
142
142
|
},
|
|
143
143
|
linkCookieName: options.linkCookieName ?? "link_intent",
|
|
144
|
+
stateCookieName: options.stateCookieName ?? "oauth_state",
|
|
144
145
|
accessTtlSeconds: options.accessTtlSeconds ?? 15 * 60,
|
|
145
146
|
refreshTtlSeconds: options.refreshTtlSeconds ?? 30 * 24 * 60 * 60,
|
|
146
147
|
linkTtlSeconds: options.linkTtlSeconds ?? 10 * 60,
|
|
148
|
+
stateTtlSeconds: options.stateTtlSeconds ?? 10 * 60,
|
|
147
149
|
accessSecret: options.accessSecret,
|
|
148
150
|
refreshSecret: options.refreshSecret,
|
|
149
151
|
singleSession: options.singleSession ?? true,
|
|
@@ -157,7 +159,7 @@ function resolveOAuthOptions(options) {
|
|
|
157
159
|
|
|
158
160
|
// src/auth/oauth/oauth-auth.service.ts
|
|
159
161
|
import { Inject, Injectable, ServiceUnavailableException } from "@nestjs/common";
|
|
160
|
-
import { createHash, randomUUID } from "crypto";
|
|
162
|
+
import { createHash, randomUUID, timingSafeEqual } from "crypto";
|
|
161
163
|
import jwt from "jsonwebtoken";
|
|
162
164
|
function hashToken(token) {
|
|
163
165
|
return createHash("sha256").update(token).digest("hex");
|
|
@@ -192,8 +194,60 @@ var OAuthAuthService = class {
|
|
|
192
194
|
}
|
|
193
195
|
return found;
|
|
194
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* The provider's consent URL, together with the `state` that has to travel
|
|
199
|
+
* back with the callback.
|
|
200
|
+
*
|
|
201
|
+
* `state` is not decoration. Without it, anyone can hand a victim a
|
|
202
|
+
* callback URL carrying an authorization code for *their own* provider
|
|
203
|
+
* account: the victim's browser completes the flow and ends up signed in as
|
|
204
|
+
* the attacker, entering their data into an account someone else controls.
|
|
205
|
+
* The value goes out in the URL and, signed, into a cookie; the callback
|
|
206
|
+
* proceeds only when the two match, which a third party cannot arrange
|
|
207
|
+
* because they cannot write cookies for this domain.
|
|
208
|
+
*
|
|
209
|
+
* Signed rather than stored in a table: it needs no schema and no cleanup,
|
|
210
|
+
* and it expires on its own.
|
|
211
|
+
*/
|
|
195
212
|
authorizeUrl(name) {
|
|
196
|
-
|
|
213
|
+
const provider = this.provider(name);
|
|
214
|
+
const ttl = this.options.stateTtlSeconds;
|
|
215
|
+
const state = jwt.sign(
|
|
216
|
+
// `nonce` makes two authorize requests within the same second produce
|
|
217
|
+
// different tokens; `typ` keeps this from being swapped with the link
|
|
218
|
+
// token, which is signed with the same secret.
|
|
219
|
+
{ typ: "state", provider: provider.name, nonce: randomUUID() },
|
|
220
|
+
requireSecret(this.options.accessSecret, "JWT_ACCESS_SECRET"),
|
|
221
|
+
{ expiresIn: ttl }
|
|
222
|
+
);
|
|
223
|
+
return {
|
|
224
|
+
url: buildAuthorizeUrl(provider, state),
|
|
225
|
+
state,
|
|
226
|
+
expiresAt: new Date(Date.now() + ttl * 1e3)
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Whether the `state` the provider sent back is the one issued for this
|
|
231
|
+
* provider, to this browser, and still valid.
|
|
232
|
+
*
|
|
233
|
+
* Both halves are checked. Matching the cookie proves the callback belongs
|
|
234
|
+
* to the authorize request this browser started; verifying the signature
|
|
235
|
+
* proves the pair was not simply invented by whoever crafted the URL.
|
|
236
|
+
*/
|
|
237
|
+
verifyState(received, fromCookie, provider) {
|
|
238
|
+
if (!received || !fromCookie) return false;
|
|
239
|
+
const a = Buffer.from(received);
|
|
240
|
+
const b = Buffer.from(fromCookie);
|
|
241
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
|
|
242
|
+
try {
|
|
243
|
+
const payload = jwt.verify(
|
|
244
|
+
received,
|
|
245
|
+
requireSecret(this.options.accessSecret, "JWT_ACCESS_SECRET")
|
|
246
|
+
);
|
|
247
|
+
return payload.typ === "state" && payload.provider === provider;
|
|
248
|
+
} catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
197
251
|
}
|
|
198
252
|
/** Exchanges the callback's `code` for a normalised profile. */
|
|
199
253
|
async profileFromCode(name, code) {
|
|
@@ -491,10 +545,22 @@ function createOAuthController(options = {}) {
|
|
|
491
545
|
return { ok: true };
|
|
492
546
|
}
|
|
493
547
|
start(provider, reply) {
|
|
494
|
-
|
|
548
|
+
const { url, state, expiresAt } = this.auth.authorizeUrl(provider);
|
|
549
|
+
setCookie(reply, this.auth.config.stateCookieName, state, {
|
|
550
|
+
...this.cookieBase(),
|
|
551
|
+
expires: expiresAt
|
|
552
|
+
});
|
|
553
|
+
redirect(reply, url);
|
|
495
554
|
}
|
|
496
|
-
async callback(provider, code, request, reply) {
|
|
555
|
+
async callback(provider, code, state, request, reply) {
|
|
497
556
|
if (!code) throw new UnauthorizedAppException(this.auth.config.genericErrorMessage());
|
|
557
|
+
const stateCookie = request.cookies?.[this.auth.config.stateCookieName];
|
|
558
|
+
clearCookie(reply, this.auth.config.stateCookieName, {
|
|
559
|
+
path: this.auth.config.cookie.path
|
|
560
|
+
});
|
|
561
|
+
if (!this.auth.verifyState(state, stateCookie, provider)) {
|
|
562
|
+
throw new UnauthorizedAppException(this.auth.config.genericErrorMessage());
|
|
563
|
+
}
|
|
498
564
|
const profile = await this.auth.profileFromCode(provider, code);
|
|
499
565
|
const linkUserId = this.auth.readLinkToken(request.cookies?.[this.auth.config.linkCookieName]);
|
|
500
566
|
if (linkUserId) {
|
|
@@ -562,8 +628,9 @@ function createOAuthController(options = {}) {
|
|
|
562
628
|
UseFilters(OAuthCallbackFilter),
|
|
563
629
|
__decorateParam(0, Param("provider")),
|
|
564
630
|
__decorateParam(1, Query("code")),
|
|
565
|
-
__decorateParam(2,
|
|
566
|
-
__decorateParam(3,
|
|
631
|
+
__decorateParam(2, Query("state")),
|
|
632
|
+
__decorateParam(3, Req()),
|
|
633
|
+
__decorateParam(4, Res())
|
|
567
634
|
], OAuthControllerHost.prototype, "callback", 1);
|
|
568
635
|
OAuthControllerHost = __decorateClass([
|
|
569
636
|
Controller(options.path ?? "auth")
|
|
@@ -29,7 +29,35 @@ export declare class OAuthAuthService<U extends OAuthUser = OAuthUser> {
|
|
|
29
29
|
* differently, and distinguishing them only tells a prober what exists.
|
|
30
30
|
*/
|
|
31
31
|
provider(name: string): OAuthProviderConfig;
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The provider's consent URL, together with the `state` that has to travel
|
|
34
|
+
* back with the callback.
|
|
35
|
+
*
|
|
36
|
+
* `state` is not decoration. Without it, anyone can hand a victim a
|
|
37
|
+
* callback URL carrying an authorization code for *their own* provider
|
|
38
|
+
* account: the victim's browser completes the flow and ends up signed in as
|
|
39
|
+
* the attacker, entering their data into an account someone else controls.
|
|
40
|
+
* The value goes out in the URL and, signed, into a cookie; the callback
|
|
41
|
+
* proceeds only when the two match, which a third party cannot arrange
|
|
42
|
+
* because they cannot write cookies for this domain.
|
|
43
|
+
*
|
|
44
|
+
* Signed rather than stored in a table: it needs no schema and no cleanup,
|
|
45
|
+
* and it expires on its own.
|
|
46
|
+
*/
|
|
47
|
+
authorizeUrl(name: string): {
|
|
48
|
+
url: string;
|
|
49
|
+
state: string;
|
|
50
|
+
expiresAt: Date;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Whether the `state` the provider sent back is the one issued for this
|
|
54
|
+
* provider, to this browser, and still valid.
|
|
55
|
+
*
|
|
56
|
+
* Both halves are checked. Matching the cookie proves the callback belongs
|
|
57
|
+
* to the authorize request this browser started; verifying the signature
|
|
58
|
+
* proves the pair was not simply invented by whoever crafted the URL.
|
|
59
|
+
*/
|
|
60
|
+
verifyState(received: string | undefined, fromCookie: string | undefined, provider: string): boolean;
|
|
33
61
|
/** Exchanges the callback's `code` for a normalised profile. */
|
|
34
62
|
profileFromCode(name: string, code: string): Promise<OAuthProfile>;
|
|
35
63
|
/**
|
|
@@ -41,10 +41,21 @@ export interface OAuthAuthOptions<U extends OAuthUser = OAuthUser> {
|
|
|
41
41
|
* already signed-in account. Default `link_intent`.
|
|
42
42
|
*/
|
|
43
43
|
linkCookieName?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Cookie holding the `state` that ties an authorize request to its
|
|
46
|
+
* callback. Defaults to `oauth_state`.
|
|
47
|
+
*/
|
|
48
|
+
stateCookieName?: string;
|
|
44
49
|
/** Defaults: 15 min access, 30 days refresh, 10 min link permission. */
|
|
45
50
|
accessTtlSeconds?: number;
|
|
46
51
|
refreshTtlSeconds?: number;
|
|
47
52
|
linkTtlSeconds?: number;
|
|
53
|
+
/**
|
|
54
|
+
* How long a `state` stays valid. Defaults to ten minutes — long enough to
|
|
55
|
+
* type a password and pass a second factor, short enough that a leaked
|
|
56
|
+
* authorize URL is worthless by the time anyone finds it.
|
|
57
|
+
*/
|
|
58
|
+
stateTtlSeconds?: number;
|
|
48
59
|
/** Read from `JWT_ACCESS_SECRET` / `JWT_REFRESH_SECRET` when omitted. */
|
|
49
60
|
accessSecret?: string;
|
|
50
61
|
refreshSecret?: string;
|
package/dist/index.cjs
CHANGED
|
@@ -25,6 +25,10 @@ var _chunkTJHRABMLcjs = require('./chunk-TJHRABML.cjs');
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
var _chunk3CLYZC3Tcjs = require('./chunk-3CLYZC3T.cjs');
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
var _chunkM3EL5O6Tcjs = require('./chunk-M3EL5O6T.cjs');
|
|
28
32
|
require('./chunk-7SOM7EZP.cjs');
|
|
29
33
|
|
|
30
34
|
|
|
@@ -45,10 +49,6 @@ var _chunkR7BVS6CIcjs = require('./chunk-R7BVS6CI.cjs');
|
|
|
45
49
|
|
|
46
50
|
|
|
47
51
|
var _chunkQS2W5XCQcjs = require('./chunk-QS2W5XCQ.cjs');
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
var _chunkM3EL5O6Tcjs = require('./chunk-M3EL5O6T.cjs');
|
|
52
52
|
require('./chunk-2REOCMUD.cjs');
|
|
53
53
|
|
|
54
54
|
|
package/dist/index.js
CHANGED
|
@@ -25,6 +25,10 @@ import {
|
|
|
25
25
|
BaseCrudService,
|
|
26
26
|
createCrudController
|
|
27
27
|
} from "./chunk-JOVBJDJ2.js";
|
|
28
|
+
import {
|
|
29
|
+
I18nModule,
|
|
30
|
+
translateOr
|
|
31
|
+
} from "./chunk-IYUUYCP5.js";
|
|
28
32
|
import "./chunk-DQYAIQQ5.js";
|
|
29
33
|
import {
|
|
30
34
|
ConflictAppException,
|
|
@@ -45,10 +49,6 @@ import {
|
|
|
45
49
|
import {
|
|
46
50
|
BaseResponseDto
|
|
47
51
|
} from "./chunk-XX2HPTRU.js";
|
|
48
|
-
import {
|
|
49
|
-
I18nModule,
|
|
50
|
-
translateOr
|
|
51
|
-
} from "./chunk-IYUUYCP5.js";
|
|
52
52
|
import "./chunk-4MGIQFAJ.js";
|
|
53
53
|
export {
|
|
54
54
|
AppException,
|
package/dist/media/index.cjs
CHANGED
|
@@ -20,28 +20,60 @@ var _common = require('@nestjs/common');
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
var _crypto = require('crypto');
|
|
24
|
+
|
|
23
25
|
// src/media/media.service.ts
|
|
24
26
|
|
|
25
27
|
|
|
26
28
|
// src/media/media.options.ts
|
|
27
29
|
var MEDIA_OPTIONS = /* @__PURE__ */ Symbol("MEDIA_OPTIONS");
|
|
28
30
|
var MEDIA_TTL = /* @__PURE__ */ Symbol("MEDIA_TTL");
|
|
31
|
+
var MEDIA_LIMITS = /* @__PURE__ */ Symbol("MEDIA_LIMITS");
|
|
29
32
|
|
|
30
33
|
// src/media/media.service.ts
|
|
31
|
-
|
|
34
|
+
|
|
32
35
|
var MediaService = class {
|
|
33
|
-
constructor(ttlMs) {
|
|
36
|
+
constructor(ttlMs, limits) {
|
|
34
37
|
this.ttlMs = ttlMs;
|
|
38
|
+
this.limits = limits;
|
|
35
39
|
this.logger = new (0, _common.Logger)(MediaService.name);
|
|
36
40
|
this.store = /* @__PURE__ */ new Map();
|
|
37
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Stores a file, or throws if the store is already full.
|
|
44
|
+
*
|
|
45
|
+
* The ceiling matters more than it looks. This map is the process's own
|
|
46
|
+
* heap, entries live for an hour, and nothing here bounded how many there
|
|
47
|
+
* could be: a loop against the upload endpoint — a bug in a publishing job
|
|
48
|
+
* as easily as someone with the secret — grows it until the process dies,
|
|
49
|
+
* and it takes the whole API down with it, not just uploads.
|
|
50
|
+
*
|
|
51
|
+
* Refusing is the right answer rather than evicting the oldest: an eviction
|
|
52
|
+
* would silently break a publication already in flight, and the caller
|
|
53
|
+
* cannot tell that its image vanished until the remote service reports a
|
|
54
|
+
* 404 it cannot explain.
|
|
55
|
+
*/
|
|
38
56
|
put(data, contentType) {
|
|
39
57
|
this.sweep();
|
|
58
|
+
if (this.store.size >= this.limits.maxFiles) {
|
|
59
|
+
throw new (0, _common.PayloadTooLargeException)(
|
|
60
|
+
`The media store is full (${this.limits.maxFiles} files). Try again shortly.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const total = this.bytesHeld();
|
|
64
|
+
if (total + data.length > this.limits.maxTotalBytes) {
|
|
65
|
+
throw new (0, _common.PayloadTooLargeException)("The media store is out of room. Try again shortly.");
|
|
66
|
+
}
|
|
40
67
|
const id = _crypto.randomUUID.call(void 0, ).replace(/-/g, "");
|
|
41
68
|
this.store.set(id, { data, contentType, expiresAt: Date.now() + this.ttlMs });
|
|
42
69
|
this.logger.log(`Stored ${id} (${(data.length / 1024).toFixed(0)} KB)`);
|
|
43
70
|
return id;
|
|
44
71
|
}
|
|
72
|
+
bytesHeld() {
|
|
73
|
+
let total = 0;
|
|
74
|
+
for (const entry of this.store.values()) total += entry.data.length;
|
|
75
|
+
return total;
|
|
76
|
+
}
|
|
45
77
|
get(id) {
|
|
46
78
|
const found = this.store.get(id);
|
|
47
79
|
if (!found) return void 0;
|
|
@@ -61,10 +93,18 @@ var MediaService = class {
|
|
|
61
93
|
};
|
|
62
94
|
MediaService = exports.MediaService = _chunk2REOCMUDcjs.__decorateClass.call(void 0, [
|
|
63
95
|
_common.Injectable.call(void 0, ),
|
|
64
|
-
_chunk2REOCMUDcjs.__decorateParam.call(void 0, 0, _common.Inject.call(void 0, MEDIA_TTL))
|
|
96
|
+
_chunk2REOCMUDcjs.__decorateParam.call(void 0, 0, _common.Inject.call(void 0, MEDIA_TTL)),
|
|
97
|
+
_chunk2REOCMUDcjs.__decorateParam.call(void 0, 1, _common.Inject.call(void 0, MEDIA_LIMITS))
|
|
65
98
|
], MediaService);
|
|
66
99
|
|
|
67
100
|
// src/media/media.controller.ts
|
|
101
|
+
function secretMatches(received, expected) {
|
|
102
|
+
if (!received) return false;
|
|
103
|
+
const a = Buffer.from(received);
|
|
104
|
+
const b = Buffer.from(expected);
|
|
105
|
+
return a.length === b.length && _crypto.timingSafeEqual.call(void 0, a, b);
|
|
106
|
+
}
|
|
107
|
+
var DEFAULT_ALLOWED = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
|
68
108
|
var MediaController = class {
|
|
69
109
|
// Both dependencies are injected by explicit token, like the rest of this
|
|
70
110
|
// package. The bundler does not emit decorator metadata, so injecting by type
|
|
@@ -78,18 +118,25 @@ var MediaController = class {
|
|
|
78
118
|
if (!this.options.uploadSecret) {
|
|
79
119
|
throw new (0, _common.UnauthorizedException)("Media uploads are not configured.");
|
|
80
120
|
}
|
|
81
|
-
if (secret
|
|
121
|
+
if (!secretMatches(secret, this.options.uploadSecret)) {
|
|
82
122
|
throw new (0, _common.UnauthorizedException)("Invalid secret.");
|
|
83
123
|
}
|
|
84
124
|
const data = request.body;
|
|
85
125
|
if (!_optionalChain([data, 'optionalAccess', _ => _.length])) throw new (0, _common.UnauthorizedException)("Empty body.");
|
|
86
|
-
const
|
|
126
|
+
const allowed = _nullishCoalesce(this.options.allowedContentTypes, () => ( DEFAULT_ALLOWED));
|
|
127
|
+
const declared = _optionalChain([request, 'access', _2 => _2.headers, 'access', _3 => _3["content-type"], 'optionalAccess', _4 => _4.split, 'call', _5 => _5(";"), 'access', _6 => _6[0], 'optionalAccess', _7 => _7.trim, 'call', _8 => _8(), 'access', _9 => _9.toLowerCase, 'call', _10 => _10()]);
|
|
128
|
+
if (!declared || !allowed.includes(declared)) {
|
|
129
|
+
throw new (0, _common.BadRequestException)(
|
|
130
|
+
`Unsupported content type. Allowed: ${allowed.join(", ")}.`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const id = this.media.put(Buffer.from(data), declared);
|
|
87
134
|
return { url: `${this.options.publicUrl}/media/${id}` };
|
|
88
135
|
}
|
|
89
136
|
serve(id, reply) {
|
|
90
137
|
const found = this.media.get(id);
|
|
91
138
|
if (!found) throw new (0, _common.NotFoundException)("That file is no longer available.");
|
|
92
|
-
reply.header("content-type", found.contentType).header("cache-control", "no-store").send(found.data);
|
|
139
|
+
reply.header("content-type", found.contentType).header("cache-control", "no-store").header("x-content-type-options", "nosniff").send(found.data);
|
|
93
140
|
}
|
|
94
141
|
};
|
|
95
142
|
_chunk2REOCMUDcjs.__decorateClass.call(void 0, [
|
|
@@ -110,6 +157,7 @@ MediaController = _chunk2REOCMUDcjs.__decorateClass.call(void 0, [
|
|
|
110
157
|
|
|
111
158
|
// src/media/media.module.ts
|
|
112
159
|
var ONE_HOUR = 60 * 60 * 1e3;
|
|
160
|
+
var DEFAULT_LIMITS = { maxFiles: 50, maxTotalBytes: 256 * 1024 * 1024 };
|
|
113
161
|
var MediaModule = class {
|
|
114
162
|
static forRoot(options) {
|
|
115
163
|
return {
|
|
@@ -118,6 +166,7 @@ var MediaModule = class {
|
|
|
118
166
|
providers: [
|
|
119
167
|
{ provide: MEDIA_OPTIONS, useValue: options },
|
|
120
168
|
{ provide: MEDIA_TTL, useValue: _nullishCoalesce(options.ttlMs, () => ( ONE_HOUR)) },
|
|
169
|
+
{ provide: MEDIA_LIMITS, useValue: { ...DEFAULT_LIMITS, ...options.limits } },
|
|
121
170
|
MediaService
|
|
122
171
|
],
|
|
123
172
|
exports: [MediaService]
|
|
@@ -132,4 +181,5 @@ MediaModule = exports.MediaModule = _chunk2REOCMUDcjs.__decorateClass.call(void
|
|
|
132
181
|
|
|
133
182
|
|
|
134
183
|
|
|
135
|
-
|
|
184
|
+
|
|
185
|
+
exports.MEDIA_LIMITS = MEDIA_LIMITS; exports.MEDIA_OPTIONS = MEDIA_OPTIONS; exports.MEDIA_TTL = MEDIA_TTL; exports.MediaModule = MediaModule; exports.MediaService = MediaService;
|
package/dist/media/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { MediaModule } from './media.module.js';
|
|
2
2
|
export { MediaService } from './media.service.js';
|
|
3
|
-
export { MEDIA_OPTIONS, MEDIA_TTL } from './media.options.js';
|
|
4
|
-
export type { MediaOptions } from './media.options.js';
|
|
3
|
+
export { MEDIA_OPTIONS, MEDIA_TTL, MEDIA_LIMITS } from './media.options.js';
|
|
4
|
+
export type { MediaOptions, MediaLimits } from './media.options.js';
|
|
5
5
|
export type { StoredMedia } from './media.service.js';
|
package/dist/media/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { Module } from "@nestjs/common";
|
|
|
8
8
|
|
|
9
9
|
// src/media/media.controller.ts
|
|
10
10
|
import {
|
|
11
|
+
BadRequestException,
|
|
11
12
|
Controller,
|
|
12
13
|
Get,
|
|
13
14
|
Headers,
|
|
@@ -19,29 +20,60 @@ import {
|
|
|
19
20
|
Res,
|
|
20
21
|
UnauthorizedException
|
|
21
22
|
} from "@nestjs/common";
|
|
23
|
+
import { timingSafeEqual } from "crypto";
|
|
22
24
|
|
|
23
25
|
// src/media/media.service.ts
|
|
24
|
-
import { Inject, Injectable, Logger } from "@nestjs/common";
|
|
26
|
+
import { Inject, Injectable, Logger, PayloadTooLargeException } from "@nestjs/common";
|
|
25
27
|
|
|
26
28
|
// src/media/media.options.ts
|
|
27
29
|
var MEDIA_OPTIONS = /* @__PURE__ */ Symbol("MEDIA_OPTIONS");
|
|
28
30
|
var MEDIA_TTL = /* @__PURE__ */ Symbol("MEDIA_TTL");
|
|
31
|
+
var MEDIA_LIMITS = /* @__PURE__ */ Symbol("MEDIA_LIMITS");
|
|
29
32
|
|
|
30
33
|
// src/media/media.service.ts
|
|
31
34
|
import { randomUUID } from "crypto";
|
|
32
35
|
var MediaService = class {
|
|
33
|
-
constructor(ttlMs) {
|
|
36
|
+
constructor(ttlMs, limits) {
|
|
34
37
|
this.ttlMs = ttlMs;
|
|
38
|
+
this.limits = limits;
|
|
35
39
|
this.logger = new Logger(MediaService.name);
|
|
36
40
|
this.store = /* @__PURE__ */ new Map();
|
|
37
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Stores a file, or throws if the store is already full.
|
|
44
|
+
*
|
|
45
|
+
* The ceiling matters more than it looks. This map is the process's own
|
|
46
|
+
* heap, entries live for an hour, and nothing here bounded how many there
|
|
47
|
+
* could be: a loop against the upload endpoint — a bug in a publishing job
|
|
48
|
+
* as easily as someone with the secret — grows it until the process dies,
|
|
49
|
+
* and it takes the whole API down with it, not just uploads.
|
|
50
|
+
*
|
|
51
|
+
* Refusing is the right answer rather than evicting the oldest: an eviction
|
|
52
|
+
* would silently break a publication already in flight, and the caller
|
|
53
|
+
* cannot tell that its image vanished until the remote service reports a
|
|
54
|
+
* 404 it cannot explain.
|
|
55
|
+
*/
|
|
38
56
|
put(data, contentType) {
|
|
39
57
|
this.sweep();
|
|
58
|
+
if (this.store.size >= this.limits.maxFiles) {
|
|
59
|
+
throw new PayloadTooLargeException(
|
|
60
|
+
`The media store is full (${this.limits.maxFiles} files). Try again shortly.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const total = this.bytesHeld();
|
|
64
|
+
if (total + data.length > this.limits.maxTotalBytes) {
|
|
65
|
+
throw new PayloadTooLargeException("The media store is out of room. Try again shortly.");
|
|
66
|
+
}
|
|
40
67
|
const id = randomUUID().replace(/-/g, "");
|
|
41
68
|
this.store.set(id, { data, contentType, expiresAt: Date.now() + this.ttlMs });
|
|
42
69
|
this.logger.log(`Stored ${id} (${(data.length / 1024).toFixed(0)} KB)`);
|
|
43
70
|
return id;
|
|
44
71
|
}
|
|
72
|
+
bytesHeld() {
|
|
73
|
+
let total = 0;
|
|
74
|
+
for (const entry of this.store.values()) total += entry.data.length;
|
|
75
|
+
return total;
|
|
76
|
+
}
|
|
45
77
|
get(id) {
|
|
46
78
|
const found = this.store.get(id);
|
|
47
79
|
if (!found) return void 0;
|
|
@@ -61,10 +93,18 @@ var MediaService = class {
|
|
|
61
93
|
};
|
|
62
94
|
MediaService = __decorateClass([
|
|
63
95
|
Injectable(),
|
|
64
|
-
__decorateParam(0, Inject(MEDIA_TTL))
|
|
96
|
+
__decorateParam(0, Inject(MEDIA_TTL)),
|
|
97
|
+
__decorateParam(1, Inject(MEDIA_LIMITS))
|
|
65
98
|
], MediaService);
|
|
66
99
|
|
|
67
100
|
// src/media/media.controller.ts
|
|
101
|
+
function secretMatches(received, expected) {
|
|
102
|
+
if (!received) return false;
|
|
103
|
+
const a = Buffer.from(received);
|
|
104
|
+
const b = Buffer.from(expected);
|
|
105
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
106
|
+
}
|
|
107
|
+
var DEFAULT_ALLOWED = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
|
68
108
|
var MediaController = class {
|
|
69
109
|
// Both dependencies are injected by explicit token, like the rest of this
|
|
70
110
|
// package. The bundler does not emit decorator metadata, so injecting by type
|
|
@@ -78,18 +118,25 @@ var MediaController = class {
|
|
|
78
118
|
if (!this.options.uploadSecret) {
|
|
79
119
|
throw new UnauthorizedException("Media uploads are not configured.");
|
|
80
120
|
}
|
|
81
|
-
if (secret
|
|
121
|
+
if (!secretMatches(secret, this.options.uploadSecret)) {
|
|
82
122
|
throw new UnauthorizedException("Invalid secret.");
|
|
83
123
|
}
|
|
84
124
|
const data = request.body;
|
|
85
125
|
if (!data?.length) throw new UnauthorizedException("Empty body.");
|
|
86
|
-
const
|
|
126
|
+
const allowed = this.options.allowedContentTypes ?? DEFAULT_ALLOWED;
|
|
127
|
+
const declared = request.headers["content-type"]?.split(";")[0]?.trim().toLowerCase();
|
|
128
|
+
if (!declared || !allowed.includes(declared)) {
|
|
129
|
+
throw new BadRequestException(
|
|
130
|
+
`Unsupported content type. Allowed: ${allowed.join(", ")}.`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const id = this.media.put(Buffer.from(data), declared);
|
|
87
134
|
return { url: `${this.options.publicUrl}/media/${id}` };
|
|
88
135
|
}
|
|
89
136
|
serve(id, reply) {
|
|
90
137
|
const found = this.media.get(id);
|
|
91
138
|
if (!found) throw new NotFoundException("That file is no longer available.");
|
|
92
|
-
reply.header("content-type", found.contentType).header("cache-control", "no-store").send(found.data);
|
|
139
|
+
reply.header("content-type", found.contentType).header("cache-control", "no-store").header("x-content-type-options", "nosniff").send(found.data);
|
|
93
140
|
}
|
|
94
141
|
};
|
|
95
142
|
__decorateClass([
|
|
@@ -110,6 +157,7 @@ MediaController = __decorateClass([
|
|
|
110
157
|
|
|
111
158
|
// src/media/media.module.ts
|
|
112
159
|
var ONE_HOUR = 60 * 60 * 1e3;
|
|
160
|
+
var DEFAULT_LIMITS = { maxFiles: 50, maxTotalBytes: 256 * 1024 * 1024 };
|
|
113
161
|
var MediaModule = class {
|
|
114
162
|
static forRoot(options) {
|
|
115
163
|
return {
|
|
@@ -118,6 +166,7 @@ var MediaModule = class {
|
|
|
118
166
|
providers: [
|
|
119
167
|
{ provide: MEDIA_OPTIONS, useValue: options },
|
|
120
168
|
{ provide: MEDIA_TTL, useValue: options.ttlMs ?? ONE_HOUR },
|
|
169
|
+
{ provide: MEDIA_LIMITS, useValue: { ...DEFAULT_LIMITS, ...options.limits } },
|
|
121
170
|
MediaService
|
|
122
171
|
],
|
|
123
172
|
exports: [MediaService]
|
|
@@ -128,6 +177,7 @@ MediaModule = __decorateClass([
|
|
|
128
177
|
Module({})
|
|
129
178
|
], MediaModule);
|
|
130
179
|
export {
|
|
180
|
+
MEDIA_LIMITS,
|
|
131
181
|
MEDIA_OPTIONS,
|
|
132
182
|
MEDIA_TTL,
|
|
133
183
|
MediaModule,
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export declare const MEDIA_OPTIONS: unique symbol;
|
|
2
2
|
export declare const MEDIA_TTL: unique symbol;
|
|
3
|
+
export declare const MEDIA_LIMITS: unique symbol;
|
|
4
|
+
/** What the store refuses to exceed. See `MediaService` for why it has to. */
|
|
5
|
+
export interface MediaLimits {
|
|
6
|
+
maxFiles: number;
|
|
7
|
+
maxTotalBytes: number;
|
|
8
|
+
}
|
|
3
9
|
export interface MediaOptions {
|
|
4
10
|
/**
|
|
5
11
|
* Base URL the generated links point to, **including the global prefix** if
|
|
@@ -9,6 +15,20 @@ export interface MediaOptions {
|
|
|
9
15
|
publicUrl: string;
|
|
10
16
|
/** Shared secret required to upload. Without it, uploads are refused. */
|
|
11
17
|
uploadSecret?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Content types accepted on upload and echoed back when serving.
|
|
20
|
+
*
|
|
21
|
+
* Defaults to PNG, JPEG, WebP and GIF — deliberately **not** SVG. An SVG is
|
|
22
|
+
* a document that can carry script, and serving one from your own domain
|
|
23
|
+
* runs that script in your application's origin: same cookies, same
|
|
24
|
+
* storage. Widen this only for formats that cannot execute.
|
|
25
|
+
*/
|
|
26
|
+
allowedContentTypes?: string[];
|
|
27
|
+
/**
|
|
28
|
+
* Ceiling on what the store may hold at once. Defaults to 50 files and
|
|
29
|
+
* 256 MB. Uploads beyond it are refused rather than queued.
|
|
30
|
+
*/
|
|
31
|
+
limits?: Partial<MediaLimits>;
|
|
12
32
|
/** How long a file stays available. Defaults to one hour. */
|
|
13
33
|
ttlMs?: number;
|
|
14
34
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type MediaLimits } from './media.options.js';
|
|
1
2
|
export interface StoredMedia {
|
|
2
3
|
data: Buffer;
|
|
3
4
|
contentType: string;
|
|
@@ -18,10 +19,26 @@ export interface StoredMedia {
|
|
|
18
19
|
*/
|
|
19
20
|
export declare class MediaService {
|
|
20
21
|
private readonly ttlMs;
|
|
22
|
+
private readonly limits;
|
|
21
23
|
private readonly logger;
|
|
22
24
|
private readonly store;
|
|
23
|
-
constructor(ttlMs: number);
|
|
25
|
+
constructor(ttlMs: number, limits: MediaLimits);
|
|
26
|
+
/**
|
|
27
|
+
* Stores a file, or throws if the store is already full.
|
|
28
|
+
*
|
|
29
|
+
* The ceiling matters more than it looks. This map is the process's own
|
|
30
|
+
* heap, entries live for an hour, and nothing here bounded how many there
|
|
31
|
+
* could be: a loop against the upload endpoint — a bug in a publishing job
|
|
32
|
+
* as easily as someone with the secret — grows it until the process dies,
|
|
33
|
+
* and it takes the whole API down with it, not just uploads.
|
|
34
|
+
*
|
|
35
|
+
* Refusing is the right answer rather than evicting the oldest: an eviction
|
|
36
|
+
* would silently break a publication already in flight, and the caller
|
|
37
|
+
* cannot tell that its image vanished until the remote service reports a
|
|
38
|
+
* 404 it cannot explain.
|
|
39
|
+
*/
|
|
24
40
|
put(data: Buffer, contentType: string): string;
|
|
41
|
+
private bytesHeld;
|
|
25
42
|
get(id: string): StoredMedia | undefined;
|
|
26
43
|
/** Swept on write, which is the only moment the map can have grown. */
|
|
27
44
|
private sweep;
|
package/dist/tiktok/index.cjs
CHANGED
|
@@ -13,9 +13,13 @@ var TikTokService = class {
|
|
|
13
13
|
return Boolean(this.credentials.accessToken);
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
* Datos de la cuenta
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Datos de la cuenta: qué niveles de privacidad admite —varían según si es
|
|
17
|
+
* privada, de empresa o de un menor— y cuánto puede durar el video.
|
|
18
|
+
*
|
|
19
|
+
* **Obligatorio solo para publicar en directo.** Para el modo borrador no
|
|
20
|
+
* hace falta, y llamarlo con un token que solo tiene `video.upload` falla
|
|
21
|
+
* con `scope_not_authorized`, porque este endpoint pide `video.publish`.
|
|
22
|
+
* De ahí que `publish()` solo lo consulte cuando va a publicar directo.
|
|
19
23
|
*/
|
|
20
24
|
async creatorInfo() {
|
|
21
25
|
return this.request("/post/publish/creator_info/query/", {});
|
|
@@ -65,6 +69,19 @@ var TikTokService = class {
|
|
|
65
69
|
}
|
|
66
70
|
return inicio.publish_id;
|
|
67
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Quién es el dueño del token.
|
|
74
|
+
*
|
|
75
|
+
* Sirve para comprobar que el token vive sin publicar nada: `creatorInfo`
|
|
76
|
+
* no vale para eso porque pide un permiso que un token de borradores no
|
|
77
|
+
* tiene. Solo necesita `user.info.basic`.
|
|
78
|
+
*/
|
|
79
|
+
async userInfo() {
|
|
80
|
+
const datos = await this.get(
|
|
81
|
+
"/user/info/?fields=open_id,display_name"
|
|
82
|
+
);
|
|
83
|
+
return datos.user;
|
|
84
|
+
}
|
|
68
85
|
/** En qué va el procesado. Termina en `PUBLISH_COMPLETE` o en un fallo. */
|
|
69
86
|
async status(publishId) {
|
|
70
87
|
return this.request(
|
|
@@ -72,17 +89,23 @@ var TikTokService = class {
|
|
|
72
89
|
{ publish_id: publishId }
|
|
73
90
|
);
|
|
74
91
|
}
|
|
75
|
-
|
|
92
|
+
get(path) {
|
|
93
|
+
return this.llamar(path, "GET");
|
|
94
|
+
}
|
|
95
|
+
request(path, body) {
|
|
96
|
+
return this.llamar(path, "POST", body);
|
|
97
|
+
}
|
|
98
|
+
async llamar(path, method, body) {
|
|
76
99
|
if (!this.credentials.accessToken) {
|
|
77
100
|
throw new Error("TikTokService: no access token configured");
|
|
78
101
|
}
|
|
79
102
|
const response = await fetch(`${API}${path}`, {
|
|
80
|
-
method
|
|
103
|
+
method,
|
|
81
104
|
headers: {
|
|
82
105
|
Authorization: `Bearer ${this.credentials.accessToken}`,
|
|
83
106
|
"Content-Type": "application/json; charset=UTF-8"
|
|
84
107
|
},
|
|
85
|
-
body: JSON.stringify(body)
|
|
108
|
+
...method === "POST" ? { body: JSON.stringify(body) } : {}
|
|
86
109
|
});
|
|
87
110
|
const text = await response.text();
|
|
88
111
|
let json;
|
package/dist/tiktok/index.js
CHANGED
|
@@ -13,9 +13,13 @@ var TikTokService = class {
|
|
|
13
13
|
return Boolean(this.credentials.accessToken);
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
* Datos de la cuenta
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Datos de la cuenta: qué niveles de privacidad admite —varían según si es
|
|
17
|
+
* privada, de empresa o de un menor— y cuánto puede durar el video.
|
|
18
|
+
*
|
|
19
|
+
* **Obligatorio solo para publicar en directo.** Para el modo borrador no
|
|
20
|
+
* hace falta, y llamarlo con un token que solo tiene `video.upload` falla
|
|
21
|
+
* con `scope_not_authorized`, porque este endpoint pide `video.publish`.
|
|
22
|
+
* De ahí que `publish()` solo lo consulte cuando va a publicar directo.
|
|
19
23
|
*/
|
|
20
24
|
async creatorInfo() {
|
|
21
25
|
return this.request("/post/publish/creator_info/query/", {});
|
|
@@ -65,6 +69,19 @@ var TikTokService = class {
|
|
|
65
69
|
}
|
|
66
70
|
return inicio.publish_id;
|
|
67
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Quién es el dueño del token.
|
|
74
|
+
*
|
|
75
|
+
* Sirve para comprobar que el token vive sin publicar nada: `creatorInfo`
|
|
76
|
+
* no vale para eso porque pide un permiso que un token de borradores no
|
|
77
|
+
* tiene. Solo necesita `user.info.basic`.
|
|
78
|
+
*/
|
|
79
|
+
async userInfo() {
|
|
80
|
+
const datos = await this.get(
|
|
81
|
+
"/user/info/?fields=open_id,display_name"
|
|
82
|
+
);
|
|
83
|
+
return datos.user;
|
|
84
|
+
}
|
|
68
85
|
/** En qué va el procesado. Termina en `PUBLISH_COMPLETE` o en un fallo. */
|
|
69
86
|
async status(publishId) {
|
|
70
87
|
return this.request(
|
|
@@ -72,17 +89,23 @@ var TikTokService = class {
|
|
|
72
89
|
{ publish_id: publishId }
|
|
73
90
|
);
|
|
74
91
|
}
|
|
75
|
-
|
|
92
|
+
get(path) {
|
|
93
|
+
return this.llamar(path, "GET");
|
|
94
|
+
}
|
|
95
|
+
request(path, body) {
|
|
96
|
+
return this.llamar(path, "POST", body);
|
|
97
|
+
}
|
|
98
|
+
async llamar(path, method, body) {
|
|
76
99
|
if (!this.credentials.accessToken) {
|
|
77
100
|
throw new Error("TikTokService: no access token configured");
|
|
78
101
|
}
|
|
79
102
|
const response = await fetch(`${API}${path}`, {
|
|
80
|
-
method
|
|
103
|
+
method,
|
|
81
104
|
headers: {
|
|
82
105
|
Authorization: `Bearer ${this.credentials.accessToken}`,
|
|
83
106
|
"Content-Type": "application/json; charset=UTF-8"
|
|
84
107
|
},
|
|
85
|
-
body: JSON.stringify(body)
|
|
108
|
+
...method === "POST" ? { body: JSON.stringify(body) } : {}
|
|
86
109
|
});
|
|
87
110
|
const text = await response.text();
|
|
88
111
|
let json;
|
|
@@ -29,10 +29,10 @@ export interface TikTokVideo {
|
|
|
29
29
|
/**
|
|
30
30
|
* Publica vídeos con la Content Posting API de TikTok.
|
|
31
31
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
32
|
+
* Publicar son dos llamadas: inicializar la subida y sondear hasta que TikTok
|
|
33
|
+
* termine de procesar. Hay una tercera, `creatorInfo`, que es obligatoria
|
|
34
|
+
* **solo** para publicar en directo —y que además exige `video.publish`, así
|
|
35
|
+
* que llamarla con un token de borradores falla.
|
|
36
36
|
*
|
|
37
37
|
* **Dos modos, y la diferencia importa mucho al principio.** En *Direct Post*
|
|
38
38
|
* el vídeo sale en vivo, pero exige el permiso `video.publish`, que solo se
|
|
@@ -54,9 +54,13 @@ export declare class TikTokService {
|
|
|
54
54
|
constructor(credentials: TikTokCredentials);
|
|
55
55
|
get configured(): boolean;
|
|
56
56
|
/**
|
|
57
|
-
* Datos de la cuenta
|
|
58
|
-
*
|
|
59
|
-
*
|
|
57
|
+
* Datos de la cuenta: qué niveles de privacidad admite —varían según si es
|
|
58
|
+
* privada, de empresa o de un menor— y cuánto puede durar el video.
|
|
59
|
+
*
|
|
60
|
+
* **Obligatorio solo para publicar en directo.** Para el modo borrador no
|
|
61
|
+
* hace falta, y llamarlo con un token que solo tiene `video.upload` falla
|
|
62
|
+
* con `scope_not_authorized`, porque este endpoint pide `video.publish`.
|
|
63
|
+
* De ahí que `publish()` solo lo consulte cuando va a publicar directo.
|
|
60
64
|
*/
|
|
61
65
|
creatorInfo(): Promise<TikTokCreatorInfo>;
|
|
62
66
|
/**
|
|
@@ -66,10 +70,23 @@ export declare class TikTokService {
|
|
|
66
70
|
* hace falta que la aplicación haya pasado la auditoría.
|
|
67
71
|
*/
|
|
68
72
|
publish(video: TikTokVideo): Promise<string>;
|
|
73
|
+
/**
|
|
74
|
+
* Quién es el dueño del token.
|
|
75
|
+
*
|
|
76
|
+
* Sirve para comprobar que el token vive sin publicar nada: `creatorInfo`
|
|
77
|
+
* no vale para eso porque pide un permiso que un token de borradores no
|
|
78
|
+
* tiene. Solo necesita `user.info.basic`.
|
|
79
|
+
*/
|
|
80
|
+
userInfo(): Promise<{
|
|
81
|
+
open_id: string;
|
|
82
|
+
display_name: string;
|
|
83
|
+
}>;
|
|
69
84
|
/** En qué va el procesado. Termina en `PUBLISH_COMPLETE` o en un fallo. */
|
|
70
85
|
status(publishId: string): Promise<{
|
|
71
86
|
status: string;
|
|
72
87
|
fail_reason?: string;
|
|
73
88
|
}>;
|
|
89
|
+
private get;
|
|
74
90
|
private request;
|
|
91
|
+
private llamar;
|
|
75
92
|
}
|