@adonis-agora/authkit-server 0.50.0 → 0.51.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/build/host/views/login.edge +8 -3
- package/build/src/define_config.d.ts +7 -0
- package/build/src/host/controllers/interaction_controller.js +15 -2
- package/build/src/host/default_mailer.d.ts +1 -0
- package/build/src/host/default_mailer.js +35 -14
- package/build/src/host/email_templates.d.ts +11 -4
- package/build/src/host/email_templates.js +7 -6
- package/build/src/host/i18n.d.ts +6 -0
- package/build/src/host/i18n.js +8 -0
- package/build/src/host/login_channel.d.ts +30 -0
- package/build/src/host/login_channel.js +26 -0
- package/package.json +1 -1
|
@@ -190,14 +190,19 @@
|
|
|
190
190
|
@end
|
|
191
191
|
@end
|
|
192
192
|
|
|
193
|
-
{{-- Passwordless: confirmação de magic link enviado (anti-enumeração).
|
|
193
|
+
{{-- Passwordless: confirmação de magic link enviado (anti-enumeração).
|
|
194
|
+
`magicChannel` (choose-first) escolhe a sub-view: 'code' = só o campo de
|
|
195
|
+
código, 'link' = só o aviso de link, 'both'/ausente = ambos (histórico). --}}
|
|
194
196
|
@if(magicLinkSent)
|
|
195
|
-
|
|
197
|
+
@if(magicChannel !== 'code')
|
|
198
|
+
<p class="mt-4 rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">{{ t('login.magic_link_sent') }}</p>
|
|
199
|
+
@end
|
|
196
200
|
|
|
197
201
|
{{-- Login por OTP: campo de código digitável (mesmo e-mail carrega link E código). --}}
|
|
198
|
-
@if(otpEnabled)
|
|
202
|
+
@if(otpEnabled && magicChannel !== 'link')
|
|
199
203
|
<form method="POST" action="/auth/interaction/{{ uid }}/otp-verify" class="mt-4">
|
|
200
204
|
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
205
|
+
<input type="hidden" name="channel" value="code">
|
|
201
206
|
<label for="otp-code" class="block text-sm font-medium text-gray-700">{{ t('login.otp_label') }}</label>
|
|
202
207
|
<input id="otp-code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code"
|
|
203
208
|
pattern="[0-9]*" placeholder="{{ t('login.otp_placeholder') }}"
|
|
@@ -47,6 +47,13 @@ export interface MailHooks {
|
|
|
47
47
|
* fluxo só-magic-link (back-compat).
|
|
48
48
|
*/
|
|
49
49
|
code?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Canal escolhido no seletor "choose-first": `'code'` = o host deveria
|
|
52
|
+
* renderizar SÓ o código, `'link'` = SÓ o link. Ausente = ambos (histórico).
|
|
53
|
+
* Puramente de superfície — os dois tokens continuam emitidos co-locados;
|
|
54
|
+
* hosts existentes simplesmente ignoram este campo (back-compat).
|
|
55
|
+
*/
|
|
56
|
+
channel?: 'code' | 'link';
|
|
50
57
|
}) => Promise<void>;
|
|
51
58
|
/**
|
|
52
59
|
* Envia o link de CONFIRMAÇÃO DE IDENTIDADE (sudo). Distinto de
|
|
@@ -7,6 +7,7 @@ import { sendMagicLinkEmail } from '../default_mailer.js';
|
|
|
7
7
|
import { sendOtpUnlockEmail } from '../default_mailer.js';
|
|
8
8
|
import { translate } from '../i18n.js';
|
|
9
9
|
import { attemptPasswordLogin, isEmailUnverifiedBlock } from '../login_attempt.js';
|
|
10
|
+
import { magicChannelProp, normalizeLoginChannel } from '../login_channel.js';
|
|
10
11
|
import { notifyLoginSuccess } from '../login_notify.js';
|
|
11
12
|
import { createOtpLockout, generateOtpUnlockToken, rawToDbOtpUnlockToken, resolveEffectiveOtpLockout, } from '../otp_lockout.js';
|
|
12
13
|
import { RuntimeSettings, resolveRuntimeSettings } from '../runtime_settings.js';
|
|
@@ -612,6 +613,10 @@ export default class AuthInteractionController {
|
|
|
612
613
|
// Login por OTP: liga o campo de código na tela "link enviado" quando a config
|
|
613
614
|
// está ligada E o store suporta a capacidade.
|
|
614
615
|
const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
|
|
616
|
+
// Seletor "choose-first": o host pode POSTar `channel=code|link` para pedir que
|
|
617
|
+
// o e-mail e a tela mostrem SÓ aquele método. Ausente/ inválido = both (histórico).
|
|
618
|
+
// NÃO condiciona a emissão de token — os dois continuam saindo co-locados.
|
|
619
|
+
const channel = normalizeLoginChannel(ctx.request.input('channel'));
|
|
615
620
|
if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && email) {
|
|
616
621
|
const ip = ctx.request.ip?.() ?? null;
|
|
617
622
|
const clientId = details.params.client_id ?? null;
|
|
@@ -644,10 +649,10 @@ export default class AuthInteractionController {
|
|
|
644
649
|
const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
|
|
645
650
|
const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
|
|
646
651
|
if (cfg.mail?.onMagicLink) {
|
|
647
|
-
await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token, code });
|
|
652
|
+
await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token, code, channel });
|
|
648
653
|
}
|
|
649
654
|
else {
|
|
650
|
-
await sendMagicLinkEmail(ctx, { email, magicUrl, code });
|
|
655
|
+
await sendMagicLinkEmail(ctx, { email, magicUrl, code, channel });
|
|
651
656
|
}
|
|
652
657
|
}
|
|
653
658
|
}
|
|
@@ -662,6 +667,10 @@ export default class AuthInteractionController {
|
|
|
662
667
|
brand,
|
|
663
668
|
magicLinkSent: true,
|
|
664
669
|
otpEnabled,
|
|
670
|
+
// Prop da tela: qual sub-view do estado `magicLinkSent` mostrar —
|
|
671
|
+
// 'code' (só o campo de código), 'link' (só o aviso de link) ou 'both'
|
|
672
|
+
// (ambos, quando o host não escolheu canal). Back-compat: ausente = 'both'.
|
|
673
|
+
magicChannel: magicChannelProp(channel),
|
|
665
674
|
});
|
|
666
675
|
}
|
|
667
676
|
/**
|
|
@@ -748,6 +757,9 @@ export default class AuthInteractionController {
|
|
|
748
757
|
}
|
|
749
758
|
const code = String(ctx.request.input('code', '') ?? '').trim();
|
|
750
759
|
const brand = brandFor(cfg.branding, clientId ?? undefined, undefined);
|
|
760
|
+
// Mantém a sub-view do seletor no re-render de erro (o form de código pode
|
|
761
|
+
// POSTar `channel=code`). Ausente = both (histórico).
|
|
762
|
+
const channel = normalizeLoginChannel(ctx.request.input('channel'));
|
|
751
763
|
const result = await cfg.accountStore.verifyLoginCode(email, uid, code, {
|
|
752
764
|
maxAttempts: cfg.login.otp.maxAttempts,
|
|
753
765
|
});
|
|
@@ -762,6 +774,7 @@ export default class AuthInteractionController {
|
|
|
762
774
|
brand,
|
|
763
775
|
magicLinkSent: true,
|
|
764
776
|
otpEnabled: true,
|
|
777
|
+
magicChannel: magicChannelProp(channel),
|
|
765
778
|
otpError: translate(cfg.messages, messageKey),
|
|
766
779
|
});
|
|
767
780
|
if (result.status === 'ok') {
|
|
@@ -63,6 +63,7 @@ export declare function sendMagicLinkEmail(ctx: HttpContext, data: {
|
|
|
63
63
|
email: string;
|
|
64
64
|
magicUrl: string;
|
|
65
65
|
code?: string;
|
|
66
|
+
channel?: 'code' | 'link';
|
|
66
67
|
}): Promise<void>;
|
|
67
68
|
/**
|
|
68
69
|
* Envia o e-mail de aviso de segurança ao e-mail ATUAL quando uma troca de
|
|
@@ -249,20 +249,41 @@ export async function sendMagicLinkEmail(ctx, data) {
|
|
|
249
249
|
try {
|
|
250
250
|
const brand = resolveBrand(ctx);
|
|
251
251
|
const { messages: t, locale } = resolveMailMessages(ctx);
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
252
|
+
// Login choose-first: o `channel` decide o que o e-mail SURFA (os dois tokens
|
|
253
|
+
// continuam emitidos co-locados a montante — isto é só renderização).
|
|
254
|
+
// - 'code' → e-mail SÓ com o código (sem botão/link), quando há código;
|
|
255
|
+
// - 'link' → e-mail SÓ com o link (código suprimido);
|
|
256
|
+
// - ausente → ambos (comportamento histórico — back-compat).
|
|
257
|
+
// Degradação limpa: `channel: 'code'` sem código emitido (OTP desligado) cai
|
|
258
|
+
// no e-mail de link — não dá pra mostrar um código inexistente.
|
|
259
|
+
const codeOnly = data.channel === 'code' && !!data.code;
|
|
260
|
+
const linkOnly = data.channel === 'link';
|
|
261
|
+
const content = codeOnly
|
|
262
|
+
? renderTransactionalEmail({
|
|
263
|
+
brand,
|
|
264
|
+
locale,
|
|
265
|
+
subject: translate(t, 'mail.magic_link.code_subject'),
|
|
266
|
+
heading: translate(t, 'mail.magic_link.heading'),
|
|
267
|
+
intro: translate(t, 'mail.magic_link.code_intro'),
|
|
268
|
+
footnote: translate(t, 'mail.magic_link.fallback'),
|
|
269
|
+
// Sem `ctaUrl`: e-mail sem botão nem fallback de link — só o código.
|
|
270
|
+
code: data.code,
|
|
271
|
+
codeLabel: translate(t, 'mail.magic_link.code_only_label'),
|
|
272
|
+
})
|
|
273
|
+
: renderTransactionalEmail({
|
|
274
|
+
brand,
|
|
275
|
+
locale,
|
|
276
|
+
linkFallback: translate(t, 'mail.common.link_fallback'),
|
|
277
|
+
subject: translate(t, 'mail.magic_link.subject'),
|
|
278
|
+
heading: translate(t, 'mail.magic_link.heading'),
|
|
279
|
+
intro: translate(t, 'mail.magic_link.intro'),
|
|
280
|
+
ctaLabel: translate(t, 'mail.magic_link.cta'),
|
|
281
|
+
ctaUrl: data.magicUrl,
|
|
282
|
+
footnote: translate(t, 'mail.magic_link.fallback'),
|
|
283
|
+
// Código em destaque quando presente — suprimido no canal 'link'.
|
|
284
|
+
code: linkOnly ? undefined : data.code,
|
|
285
|
+
codeLabel: translate(t, 'mail.magic_link.code_label'),
|
|
286
|
+
});
|
|
266
287
|
const sent = await sendEmail(ctx, data.email, content);
|
|
267
288
|
if (!sent) {
|
|
268
289
|
ctx.logger.info({ magicUrl: data.magicUrl, email: data.email }, 'authkit: magic link de login (dev — @adonisjs/mail ausente)');
|
|
@@ -24,10 +24,17 @@ interface EmailTemplateInput {
|
|
|
24
24
|
heading: string;
|
|
25
25
|
/** Parágrafo de introdução (texto puro, será escapado). */
|
|
26
26
|
intro: string;
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Rótulo do botão de CTA. Opcional junto de `ctaUrl`: e-mails "só código"
|
|
29
|
+
* (login por OTP com `channel: 'code'`) não têm botão nem link.
|
|
30
|
+
*/
|
|
31
|
+
ctaLabel?: string;
|
|
32
|
+
/**
|
|
33
|
+
* URL do CTA. Quando ausente, o e-mail é renderizado SEM botão e SEM o
|
|
34
|
+
* fallback de link (usado no e-mail "só código"). Todos os callers históricos
|
|
35
|
+
* passam este campo — byte-parity preservado.
|
|
36
|
+
*/
|
|
37
|
+
ctaUrl?: string;
|
|
31
38
|
/** Linha auxiliar abaixo do botão (ex.: validade do link). */
|
|
32
39
|
footnote?: string;
|
|
33
40
|
/** Texto que precede o link de fallback (i18n). Default em inglês. */
|
|
@@ -51,11 +51,13 @@ export function renderTransactionalEmail(input) {
|
|
|
51
51
|
<tr><td style="padding:32px 28px 8px;">
|
|
52
52
|
<h1 style="margin:0 0 12px;font-size:20px;line-height:1.3;color:#111827;">${esc(input.heading)}</h1>
|
|
53
53
|
<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#374151;">${esc(input.intro)}</p>
|
|
54
|
-
${codeBlock}
|
|
55
|
-
|
|
56
|
-
</
|
|
54
|
+
${codeBlock}${input.ctaUrl
|
|
55
|
+
? `<table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:8px;background:${esc(accent)};">
|
|
56
|
+
<a href="${esc(input.ctaUrl)}" style="display:inline-block;padding:12px 24px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">${esc(input.ctaLabel ?? '')}</a>
|
|
57
|
+
</td></tr></table>`
|
|
58
|
+
: ''}
|
|
57
59
|
${input.footnote ? `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#6b7280;">${esc(input.footnote)}</p>` : ''}
|
|
58
|
-
|
|
60
|
+
${input.ctaUrl ? `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#6b7280;">${esc(linkFallback)}<br><a href="${esc(input.ctaUrl)}" style="color:${esc(accent)};word-break:break-all;">${esc(input.ctaUrl)}</a></p>` : ''}
|
|
59
61
|
</td></tr>
|
|
60
62
|
<tr><td style="padding:24px 28px 28px;border-top:1px solid #f3f4f6;">
|
|
61
63
|
<p style="margin:0;font-size:12px;line-height:1.5;color:#9ca3af;">${esc(company)} ${year}</p>
|
|
@@ -70,8 +72,7 @@ ${input.footnote ? `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;col
|
|
|
70
72
|
'',
|
|
71
73
|
input.intro,
|
|
72
74
|
...(input.code ? ['', `${codeLabel} ${input.code}`] : []),
|
|
73
|
-
'',
|
|
74
|
-
`${input.ctaLabel}: ${input.ctaUrl}`,
|
|
75
|
+
...(input.ctaUrl ? ['', `${input.ctaLabel}: ${input.ctaUrl}`] : []),
|
|
75
76
|
...(input.footnote ? ['', input.footnote] : []),
|
|
76
77
|
'',
|
|
77
78
|
`— ${company}`,
|
package/build/src/host/i18n.d.ts
CHANGED
|
@@ -518,6 +518,9 @@ export declare const DEFAULT_MESSAGES: {
|
|
|
518
518
|
'mail.magic_link.cta': string;
|
|
519
519
|
'mail.magic_link.fallback': string;
|
|
520
520
|
'mail.magic_link.code_label': string;
|
|
521
|
+
'mail.magic_link.code_subject': string;
|
|
522
|
+
'mail.magic_link.code_intro': string;
|
|
523
|
+
'mail.magic_link.code_only_label': string;
|
|
521
524
|
'mail.new_login.subject': string;
|
|
522
525
|
'mail.new_login.heading': string;
|
|
523
526
|
'mail.new_login.intro': string;
|
|
@@ -1219,6 +1222,9 @@ export declare const PT_BR_MESSAGES: {
|
|
|
1219
1222
|
'mail.magic_link.cta': string;
|
|
1220
1223
|
'mail.magic_link.fallback': string;
|
|
1221
1224
|
'mail.magic_link.code_label': string;
|
|
1225
|
+
'mail.magic_link.code_subject': string;
|
|
1226
|
+
'mail.magic_link.code_intro': string;
|
|
1227
|
+
'mail.magic_link.code_only_label': string;
|
|
1222
1228
|
'mail.new_login.subject': string;
|
|
1223
1229
|
'mail.new_login.heading': string;
|
|
1224
1230
|
'mail.new_login.intro': string;
|
package/build/src/host/i18n.js
CHANGED
|
@@ -558,6 +558,10 @@ export const DEFAULT_MESSAGES = {
|
|
|
558
558
|
'mail.magic_link.cta': 'Sign in',
|
|
559
559
|
'mail.magic_link.fallback': 'If you did not request this, you can ignore this email.',
|
|
560
560
|
'mail.magic_link.code_label': 'Or enter this code to sign in:',
|
|
561
|
+
// E-mail "só código" (login choose-first com channel: 'code'): sem botão/link.
|
|
562
|
+
'mail.magic_link.code_subject': 'Your login code',
|
|
563
|
+
'mail.magic_link.code_intro': 'Use the code below to sign in. It expires shortly and can be used once.',
|
|
564
|
+
'mail.magic_link.code_only_label': 'Enter this code to sign in:',
|
|
561
565
|
'mail.new_login.subject': 'New login to your account',
|
|
562
566
|
'mail.new_login.heading': 'New login detected',
|
|
563
567
|
'mail.new_login.intro': 'We detected a new login to your account.',
|
|
@@ -1335,6 +1339,10 @@ export const PT_BR_MESSAGES = {
|
|
|
1335
1339
|
'mail.magic_link.cta': 'Entrar',
|
|
1336
1340
|
'mail.magic_link.fallback': 'Se você não solicitou isso, pode ignorar este e-mail.',
|
|
1337
1341
|
'mail.magic_link.code_label': 'Ou digite este código para entrar:',
|
|
1342
|
+
// E-mail "só código" (login choose-first com channel: 'code'): sem botão/link.
|
|
1343
|
+
'mail.magic_link.code_subject': 'Seu código de login',
|
|
1344
|
+
'mail.magic_link.code_intro': 'Use o código abaixo para entrar. Ele expira em instantes e serve para um único acesso.',
|
|
1345
|
+
'mail.magic_link.code_only_label': 'Digite este código para entrar:',
|
|
1338
1346
|
'mail.new_login.subject': 'Novo login na sua conta',
|
|
1339
1347
|
'mail.new_login.heading': 'Novo login detectado',
|
|
1340
1348
|
'mail.new_login.intro': 'Detectamos um novo login na sua conta.',
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canal de login escolhido no seletor "choose-first" (modelo GitHub): o usuário
|
|
3
|
+
* decide PRIMEIRO como quer entrar e só então o método é executado.
|
|
4
|
+
*
|
|
5
|
+
* IMPORTANTE — o `channel` é puramente de SUPERFÍCIE: NÃO condiciona a emissão de
|
|
6
|
+
* token nem toca no codec `ml2:` / lockout / single-use-conjunto. A lib continua
|
|
7
|
+
* emitindo link E código co-locados (quando `login.otp.enabled`); o `channel` só
|
|
8
|
+
* decide o que o E-MAIL renderiza e qual sub-view a TELA mostra no estado
|
|
9
|
+
* `magicLinkSent`. Ausente/ inválido = comportamento histórico ("both").
|
|
10
|
+
*/
|
|
11
|
+
/** Método escolhido no seletor. `passkey` é slot documentado (ainda não emitido). */
|
|
12
|
+
export type LoginChannel = 'code' | 'link';
|
|
13
|
+
/**
|
|
14
|
+
* Valor da prop de render que a tela usa para escolher a sub-view do estado
|
|
15
|
+
* `magicLinkSent`: `'code'` = só campo de código, `'link'` = só aviso de link,
|
|
16
|
+
* `'both'` = ambos (comportamento histórico, quando o host não manda `channel`).
|
|
17
|
+
*/
|
|
18
|
+
export type MagicChannelProp = 'code' | 'link' | 'both';
|
|
19
|
+
/**
|
|
20
|
+
* Lê e valida o campo `channel` do body do POST `/magic`. Só `'code'` e `'link'`
|
|
21
|
+
* são aceitos; qualquer outro valor (ausente, vazio, lixo) vira `undefined`, que
|
|
22
|
+
* a lib trata como "both" — garantindo back-compat total com hosts que ainda
|
|
23
|
+
* POSTam sem o campo.
|
|
24
|
+
*/
|
|
25
|
+
export declare function normalizeLoginChannel(raw: unknown): LoginChannel | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Mapeia o `channel` do body para a prop de render `magicChannel`. Ausente
|
|
28
|
+
* (`undefined`) → `'both'`: a tela mostra as duas sub-views, como hoje.
|
|
29
|
+
*/
|
|
30
|
+
export declare function magicChannelProp(channel: LoginChannel | undefined): MagicChannelProp;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canal de login escolhido no seletor "choose-first" (modelo GitHub): o usuário
|
|
3
|
+
* decide PRIMEIRO como quer entrar e só então o método é executado.
|
|
4
|
+
*
|
|
5
|
+
* IMPORTANTE — o `channel` é puramente de SUPERFÍCIE: NÃO condiciona a emissão de
|
|
6
|
+
* token nem toca no codec `ml2:` / lockout / single-use-conjunto. A lib continua
|
|
7
|
+
* emitindo link E código co-locados (quando `login.otp.enabled`); o `channel` só
|
|
8
|
+
* decide o que o E-MAIL renderiza e qual sub-view a TELA mostra no estado
|
|
9
|
+
* `magicLinkSent`. Ausente/ inválido = comportamento histórico ("both").
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Lê e valida o campo `channel` do body do POST `/magic`. Só `'code'` e `'link'`
|
|
13
|
+
* são aceitos; qualquer outro valor (ausente, vazio, lixo) vira `undefined`, que
|
|
14
|
+
* a lib trata como "both" — garantindo back-compat total com hosts que ainda
|
|
15
|
+
* POSTam sem o campo.
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeLoginChannel(raw) {
|
|
18
|
+
return raw === 'code' || raw === 'link' ? raw : undefined;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Mapeia o `channel` do body para a prop de render `magicChannel`. Ausente
|
|
22
|
+
* (`undefined`) → `'both'`: a tela mostra as duas sub-views, como hoje.
|
|
23
|
+
*/
|
|
24
|
+
export function magicChannelProp(channel) {
|
|
25
|
+
return channel ?? 'both';
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adonis-agora/authkit-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.0",
|
|
4
4
|
"description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "dudousxd",
|