@dudousxd/adonis-authkit-server 0.6.0 → 0.7.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.
@@ -33,13 +33,26 @@
33
33
  <div class="mb-6 rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
34
34
  <h2 class="mb-1 text-sm font-semibold text-gray-900">{{ t('account.profile.section') }}</h2>
35
35
  <p class="mb-4 text-xs text-gray-500">{{ t('account.profile.intro') }}</p>
36
- <form method="POST" action="/account/security/profile" class="space-y-4">
36
+ <form method="POST" action="/account/security/profile" enctype="multipart/form-data" class="space-y-4">
37
37
  <input type="hidden" name="_csrf" value="{{ csrfToken }}">
38
+ @if(avatarUrl)
39
+ <div>
40
+ <img src="{{ avatarUrl }}" alt="avatar" class="h-16 w-16 rounded-full object-cover ring-1 ring-black/5" />
41
+ </div>
42
+ @end
38
43
  <div>
39
44
  <label class="mb-1 block text-sm font-medium text-gray-700">{{ t('account.profile.name_label') }}</label>
40
45
  <input name="name" type="text" value="{{ name }}" maxlength="255"
41
46
  class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900" />
42
47
  </div>
48
+ @if(avatarUploadSupported)
49
+ <div>
50
+ <label class="mb-1 block text-sm font-medium text-gray-700">{{ t('account.profile.avatar_upload_label') }}</label>
51
+ <input name="avatar" type="file" accept="image/jpeg,image/png,image/webp"
52
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900" />
53
+ <p class="mt-1 text-xs text-gray-500">{{ t('account.profile.avatar_upload_hint') }}</p>
54
+ </div>
55
+ @end
43
56
  <div>
44
57
  <label class="mb-1 block text-sm font-medium text-gray-700">{{ t('account.profile.avatar_label') }}</label>
45
58
  <input name="avatarUrl" type="url" value="{{ avatarUrl }}"
@@ -161,6 +161,31 @@ export interface ResolvedDeviceFlowConfig {
161
161
  enabled: boolean;
162
162
  }
163
163
  export declare function resolveDeviceFlow(input?: DeviceFlowConfigInput): ResolvedDeviceFlowConfig;
164
+ /**
165
+ * Uploads — usa o `@adonisjs/drive` JÁ configurado no app (mesmo princípio do
166
+ * mailer/limiter: a infra do host por padrão, sobreponível aqui). Hoje cobre o
167
+ * upload de avatar no console de conta. Se o drive estiver ausente, a feature
168
+ * degrada para o input de URL.
169
+ */
170
+ export interface UploadsConfigInput {
171
+ avatars?: {
172
+ /** Disk do `@adonisjs/drive` a usar. Default: o disk DEFAULT do app. */
173
+ disk?: string;
174
+ /** Diretório/prefixo das chaves. Default: 'authkit/avatars'. */
175
+ directory?: string;
176
+ /** Tamanho máximo em MB. Default: 5. */
177
+ maxSizeMb?: number;
178
+ };
179
+ }
180
+ export interface ResolvedUploadsConfig {
181
+ avatars: {
182
+ /** Disk explícito; `undefined` = disk DEFAULT do app. */
183
+ disk?: string;
184
+ directory: string;
185
+ maxSizeMb: number;
186
+ };
187
+ }
188
+ export declare function resolveUploads(input?: UploadsConfigInput): ResolvedUploadsConfig;
164
189
  /**
165
190
  * DPoP — Demonstrating Proof of Possession (RFC 9449). Quando habilitado, o
166
191
  * oidc-provider aceita DPoP proofs e emite tokens sender-constrained
@@ -337,6 +362,8 @@ export interface AuthServerConfigInput {
337
362
  dynamicRegistration?: DynamicRegistrationConfigInput;
338
363
  /** Device Authorization Grant (RFC 8628). Default: desligado. */
339
364
  deviceFlow?: DeviceFlowConfigInput;
365
+ /** Uploads (avatar) via o `@adonisjs/drive` do app. Default: drive default, 5MB. */
366
+ uploads?: UploadsConfigInput;
340
367
  /** DPoP — sender-constrained tokens (RFC 9449). Default: desligado. */
341
368
  dpop?: DpopConfigInput;
342
369
  /** Pushed Authorization Requests (RFC 9126). Default: desligado. */
@@ -401,6 +428,8 @@ export interface ResolvedServerConfig {
401
428
  dynamicRegistration: ResolvedDynamicRegistrationConfig;
402
429
  /** Device Authorization Grant resolvido (default desligado). */
403
430
  deviceFlow: ResolvedDeviceFlowConfig;
431
+ /** Uploads resolvido (avatar via drive do app; sempre presente). */
432
+ uploads: ResolvedUploadsConfig;
404
433
  /** DPoP resolvido (default desligado). */
405
434
  dpop: ResolvedDpopConfig;
406
435
  /** PAR resolvido (default desligado). */
@@ -56,6 +56,15 @@ export function resolveDynamicRegistration(input) {
56
56
  export function resolveDeviceFlow(input) {
57
57
  return { enabled: input?.enabled ?? false };
58
58
  }
59
+ export function resolveUploads(input) {
60
+ return {
61
+ avatars: {
62
+ disk: input?.avatars?.disk,
63
+ directory: input?.avatars?.directory ?? 'authkit/avatars',
64
+ maxSizeMb: input?.avatars?.maxSizeMb ?? 5,
65
+ },
66
+ };
67
+ }
59
68
  export function resolveDpop(input) {
60
69
  return { enabled: input?.enabled ?? false };
61
70
  }
@@ -173,6 +182,7 @@ export function defineConfig(config) {
173
182
  webauthn: resolveWebauthn(config.issuer, config.mfaIssuer ?? 'AuthKit', config.webauthn),
174
183
  dynamicRegistration: resolveDynamicRegistration(config.dynamicRegistration),
175
184
  deviceFlow: resolveDeviceFlow(config.deviceFlow),
185
+ uploads: resolveUploads(config.uploads),
176
186
  dpop: resolveDpop(config.dpop),
177
187
  par: resolvePar(config.par),
178
188
  stepUp: resolveStepUp(config.stepUp),
@@ -0,0 +1,58 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ import type { ResolvedUploadsConfig } from '../define_config.js';
3
+ /**
4
+ * Upload de avatar do host-kit usando o `@adonisjs/drive` JÁ configurado no app.
5
+ * Mesmo princípio do default_mailer/rate_limit: por padrão usamos a infra do
6
+ * host (o disk DEFAULT do drive do app), sem o dev precisar escrever nada; tudo
7
+ * é sobreponível via `config/authkit.ts` (`uploads.avatars`).
8
+ *
9
+ * Best-effort / fail-safe: se `@adonisjs/drive` não estiver instalado/configurado,
10
+ * {@link storeAvatar} retorna `null` e a feature degrada para o input de URL.
11
+ * Nunca lança na request por causa de drive ausente.
12
+ */
13
+ /**
14
+ * Service do `@adonisjs/drive` resolvido de forma preguiçosa. Tipado como `any` de
15
+ * propósito: a lib NÃO depende do drive em tempo de compilação (peer/opt-in).
16
+ */
17
+ type DriveService = any;
18
+ /**
19
+ * Permite reapontar/limpar o loader do drive (usado em testes).
20
+ * @internal
21
+ */
22
+ export declare function __setDriveLoaderForTests(fn: (() => Promise<DriveService | null>) | undefined): void;
23
+ /** Erro de validação do upload (mensagem já localizada no controller). */
24
+ export declare class AvatarUploadError extends Error {
25
+ reason: 'extname' | 'size';
26
+ constructor(reason: 'extname' | 'size', message: string);
27
+ }
28
+ /** File de multipart mínimo que precisamos do `request.file('avatar')`. */
29
+ export interface UploadedAvatar {
30
+ extname?: string | null;
31
+ size?: number;
32
+ /** Caminho temporário (drive lê daqui para stream). */
33
+ tmpPath?: string | null;
34
+ /** Move o arquivo para um disk do drive (API v3+). */
35
+ moveToDisk?: (key: string, options?: {
36
+ disk?: string;
37
+ }) => Promise<void>;
38
+ }
39
+ /**
40
+ * Indica se o drive do app está disponível (para a view decidir mostrar o input
41
+ * de arquivo). Best-effort: nunca lança.
42
+ */
43
+ export declare function isDriveAvailable(): Promise<boolean>;
44
+ /**
45
+ * Armazena o avatar no drive do host e retorna a URL pública.
46
+ *
47
+ * - Valida extensão (jpg/jpeg/png/webp) e tamanho (≤ maxSizeMb) — lança
48
+ * {@link AvatarUploadError} se inválido (o controller traduz/flasha).
49
+ * - Usa o disk configurado em `uploads.avatars.disk` ou o disk DEFAULT do app.
50
+ * - Se o drive estiver ausente/não-configurado → retorna `null` (degrada para URL).
51
+ *
52
+ * Nunca lança por causa de drive ausente; só lança em validação.
53
+ */
54
+ export declare function storeAvatar(_ctx: HttpContext, cfg: ResolvedUploadsConfig, file: UploadedAvatar, accountId: string, messages: {
55
+ extname: string;
56
+ size: string;
57
+ }): Promise<string | null>;
58
+ export {};
@@ -0,0 +1,125 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ let driveServicePromise;
10
+ /**
11
+ * Importa o service de drive do HOST de forma preguiçosa e fail-safe.
12
+ * Se `@adonisjs/drive` não estiver instalado, resolve `null`.
13
+ */
14
+ async function loadDrive() {
15
+ if (!driveServicePromise) {
16
+ // Indireção via variável: o `@adonisjs/drive` é peer/opcional e pode não estar
17
+ // instalado na lib, então o specifier não é resolvido em build-time.
18
+ const specifier = '@adonisjs/drive/services/main';
19
+ driveServicePromise = import(__rewriteRelativeImportExtension(specifier))
20
+ .then((mod) => mod.default ?? null)
21
+ .catch(() => null);
22
+ }
23
+ return driveServicePromise;
24
+ }
25
+ /**
26
+ * Permite reapontar/limpar o loader do drive (usado em testes).
27
+ * @internal
28
+ */
29
+ export function __setDriveLoaderForTests(fn) {
30
+ if (fn) {
31
+ driveServicePromise = fn();
32
+ }
33
+ else {
34
+ driveServicePromise = undefined;
35
+ }
36
+ }
37
+ /** Extensões aceitas para o avatar (imagem raster comum). */
38
+ const ALLOWED_EXTNAMES = ['jpg', 'jpeg', 'png', 'webp'];
39
+ /** Erro de validação do upload (mensagem já localizada no controller). */
40
+ export class AvatarUploadError extends Error {
41
+ reason;
42
+ constructor(reason, message) {
43
+ super(message);
44
+ this.reason = reason;
45
+ this.name = 'AvatarUploadError';
46
+ }
47
+ }
48
+ /**
49
+ * Indica se o drive do app está disponível (para a view decidir mostrar o input
50
+ * de arquivo). Best-effort: nunca lança.
51
+ */
52
+ export async function isDriveAvailable() {
53
+ return (await loadDrive()) !== null;
54
+ }
55
+ /**
56
+ * Resolve a extensão validada do arquivo enviado.
57
+ * Lança {@link AvatarUploadError} se ext/size forem inválidos.
58
+ */
59
+ function validate(file, cfg, messages) {
60
+ const ext = (file.extname ?? '').toLowerCase().replace(/^\./, '');
61
+ if (!ALLOWED_EXTNAMES.includes(ext)) {
62
+ throw new AvatarUploadError('extname', messages.extname);
63
+ }
64
+ const maxBytes = cfg.avatars.maxSizeMb * 1024 * 1024;
65
+ if (typeof file.size === 'number' && file.size > maxBytes) {
66
+ throw new AvatarUploadError('size', messages.size);
67
+ }
68
+ return ext;
69
+ }
70
+ /**
71
+ * Gera uma chave única para o avatar dentro do diretório configurado.
72
+ * `${directory}/${accountId}-${random}.${ext}`
73
+ */
74
+ function buildKey(cfg, accountId, ext) {
75
+ const random = Math.random().toString(36).slice(2, 10);
76
+ return `${cfg.avatars.directory}/${accountId}-${random}.${ext}`;
77
+ }
78
+ /**
79
+ * Armazena o avatar no drive do host e retorna a URL pública.
80
+ *
81
+ * - Valida extensão (jpg/jpeg/png/webp) e tamanho (≤ maxSizeMb) — lança
82
+ * {@link AvatarUploadError} se inválido (o controller traduz/flasha).
83
+ * - Usa o disk configurado em `uploads.avatars.disk` ou o disk DEFAULT do app.
84
+ * - Se o drive estiver ausente/não-configurado → retorna `null` (degrada para URL).
85
+ *
86
+ * Nunca lança por causa de drive ausente; só lança em validação.
87
+ */
88
+ export async function storeAvatar(_ctx, cfg, file, accountId, messages) {
89
+ const drive = await loadDrive();
90
+ if (!drive)
91
+ return null;
92
+ const ext = validate(file, cfg, messages);
93
+ // Resolve o disk: o configurado, ou o DEFAULT do drive do app.
94
+ let disk;
95
+ try {
96
+ disk = cfg.avatars.disk ? drive.use(cfg.avatars.disk) : drive.use();
97
+ }
98
+ catch {
99
+ // disk inválido/não-configurado — degrada para URL.
100
+ return null;
101
+ }
102
+ if (!disk)
103
+ return null;
104
+ const key = buildKey(cfg, accountId || 'account', ext);
105
+ // API @adonisjs/drive v3+: o file de multipart move-se direto para o disk.
106
+ // `moveToDisk` lê do tmpPath e usa o disk informado (ou o default da config).
107
+ if (typeof file.moveToDisk === 'function') {
108
+ await file.moveToDisk(key, cfg.avatars.disk ? { disk: cfg.avatars.disk } : undefined);
109
+ }
110
+ else if (file.tmpPath) {
111
+ // Fallback: lê do tmpPath e escreve via putStream no disk resolvido.
112
+ const fs = await import('node:fs');
113
+ await disk.putStream(key, fs.createReadStream(file.tmpPath));
114
+ }
115
+ else {
116
+ return null;
117
+ }
118
+ try {
119
+ return await disk.getUrl(key);
120
+ }
121
+ catch {
122
+ // disk sem getUrl público — retorna a key como referência relativa.
123
+ return key;
124
+ }
125
+ }
@@ -3,6 +3,7 @@ import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
3
3
  import { supportsAccountSecurity, supportsProfile } from '../../accounts/account_store.js';
4
4
  import { changePasswordValidator, changeEmailValidator, updateProfileValidator } from '../validators.js';
5
5
  import { sendEmailChangeConfirmationEmail } from '../default_mailer.js';
6
+ import { storeAvatar, isDriveAvailable, AvatarUploadError } from '../avatar_storage.js';
6
7
  import { translate } from '../i18n.js';
7
8
  import { TRUSTED_DEVICE_COOKIE } from '../trusted_device.js';
8
9
  /**
@@ -23,6 +24,8 @@ export default class AccountSecurityController {
23
24
  csrfToken: ctx.request.csrfToken,
24
25
  supported: supportsAccountSecurity(cfg.accountStore),
25
26
  profileSupported: supportsProfile(cfg.accountStore),
27
+ // Só mostramos o input de arquivo se o drive do app estiver disponível.
28
+ avatarUploadSupported: await isDriveAvailable(),
26
29
  email: account?.email ?? '',
27
30
  name: account?.name ?? '',
28
31
  avatarUrl: account?.avatarUrl ?? '',
@@ -64,16 +67,45 @@ export default class AccountSecurityController {
64
67
  return ctx.response.redirect('/account/security');
65
68
  }
66
69
  const { name, avatarUrl } = await ctx.request.validateUsing(updateProfileValidator);
70
+ // Upload de avatar via o drive do app (opt-in pela presença do arquivo).
71
+ // Se um arquivo for enviado e o drive estiver disponível, a URL resultante
72
+ // tem prioridade sobre o input de URL; senão caímos no avatarUrl (texto).
73
+ let resolvedAvatarUrl = avatarUrl ?? null;
74
+ let via = 'url';
75
+ const file = ctx.request.file('avatar', {
76
+ size: `${cfg.uploads.avatars.maxSizeMb}mb`,
77
+ extnames: ['jpg', 'jpeg', 'png', 'webp'],
78
+ });
79
+ if (file) {
80
+ try {
81
+ const uploadedUrl = await storeAvatar(ctx, cfg.uploads, file, userId, {
82
+ extname: translate(cfg.messages, 'account.profile.avatar_invalid_type'),
83
+ size: translate(cfg.messages, 'account.profile.avatar_too_large'),
84
+ });
85
+ if (uploadedUrl) {
86
+ resolvedAvatarUrl = uploadedUrl;
87
+ via = 'upload';
88
+ }
89
+ }
90
+ catch (error) {
91
+ if (error instanceof AvatarUploadError) {
92
+ ctx.session.flash('securityError', error.message);
93
+ return ctx.response.redirect('/account/security');
94
+ }
95
+ throw error;
96
+ }
97
+ }
67
98
  // Campos ausentes no form viram string vazia (limpa o valor); enviamos null
68
99
  // para limpar, ou o valor trimado.
69
100
  await store.updateProfile(userId, {
70
101
  name: name ?? null,
71
- avatarUrl: avatarUrl ?? null,
102
+ avatarUrl: resolvedAvatarUrl,
72
103
  });
73
104
  await cfg.audit?.record({
74
105
  type: 'profile.updated',
75
106
  accountId: userId,
76
107
  ip: ctx.request.ip?.() ?? null,
108
+ metadata: { via },
77
109
  });
78
110
  ctx.session.flash('profileUpdated', translate(cfg.messages, 'account.profile.updated'));
79
111
  return ctx.response.redirect('/account/security');
@@ -133,6 +133,10 @@ export declare const DEFAULT_MESSAGES: {
133
133
  'account.profile.intro': string;
134
134
  'account.profile.name_label': string;
135
135
  'account.profile.avatar_label': string;
136
+ 'account.profile.avatar_upload_label': string;
137
+ 'account.profile.avatar_upload_hint': string;
138
+ 'account.profile.avatar_invalid_type': string;
139
+ 'account.profile.avatar_too_large': string;
136
140
  'account.profile.submit': string;
137
141
  'account.profile.updated': string;
138
142
  'account.profile.not_supported': string;
@@ -432,6 +436,10 @@ export declare const PT_BR_MESSAGES: {
432
436
  'account.profile.intro': string;
433
437
  'account.profile.name_label': string;
434
438
  'account.profile.avatar_label': string;
439
+ 'account.profile.avatar_upload_label': string;
440
+ 'account.profile.avatar_upload_hint': string;
441
+ 'account.profile.avatar_invalid_type': string;
442
+ 'account.profile.avatar_too_large': string;
435
443
  'account.profile.submit': string;
436
444
  'account.profile.updated': string;
437
445
  'account.profile.not_supported': string;
@@ -135,6 +135,10 @@ export const DEFAULT_MESSAGES = {
135
135
  'account.profile.intro': 'Update your display name and avatar.',
136
136
  'account.profile.name_label': 'Name',
137
137
  'account.profile.avatar_label': 'Avatar URL',
138
+ 'account.profile.avatar_upload_label': 'Upload avatar',
139
+ 'account.profile.avatar_upload_hint': 'JPG, PNG or WebP, up to 5MB.',
140
+ 'account.profile.avatar_invalid_type': 'Invalid image type. Use JPG, PNG or WebP.',
141
+ 'account.profile.avatar_too_large': 'Image is too large.',
138
142
  'account.profile.submit': 'Save profile',
139
143
  'account.profile.updated': 'Profile updated successfully.',
140
144
  'account.profile.not_supported': 'Profile editing is not available in this installation.',
@@ -463,6 +467,10 @@ export const PT_BR_MESSAGES = {
463
467
  'account.profile.intro': 'Atualize seu nome de exibição e avatar.',
464
468
  'account.profile.name_label': 'Nome',
465
469
  'account.profile.avatar_label': 'URL do avatar',
470
+ 'account.profile.avatar_upload_label': 'Enviar avatar',
471
+ 'account.profile.avatar_upload_hint': 'JPG, PNG ou WebP, até 5MB.',
472
+ 'account.profile.avatar_invalid_type': 'Tipo de imagem inválido. Use JPG, PNG ou WebP.',
473
+ 'account.profile.avatar_too_large': 'A imagem é muito grande.',
466
474
  'account.profile.submit': 'Salvar perfil',
467
475
  'account.profile.updated': 'Perfil atualizado com sucesso.',
468
476
  'account.profile.not_supported': 'A edição de perfil não está disponível nesta instalação.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dudousxd/adonis-authkit-server",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -54,6 +54,7 @@
54
54
  "peerDependencies": {
55
55
  "@adonisjs/ally": "6.3.0",
56
56
  "@adonisjs/core": "7.3.3",
57
+ "@adonisjs/drive": "4.0.0",
57
58
  "@adonisjs/lucid": "22.4.2",
58
59
  "@adonisjs/session": "8.1.0",
59
60
  "@adonisjs/shield": "9.0.0",
@@ -65,6 +66,9 @@
65
66
  },
66
67
  "@adonisjs/ally": {
67
68
  "optional": true
69
+ },
70
+ "@adonisjs/drive": {
71
+ "optional": true
68
72
  }
69
73
  },
70
74
  "dependencies": {
@@ -81,6 +85,7 @@
81
85
  "devDependencies": {
82
86
  "@adonisjs/ally": "6.3.0",
83
87
  "@adonisjs/core": "7.3.3",
88
+ "@adonisjs/drive": "4.0.0",
84
89
  "@adonisjs/lucid": "22.4.2",
85
90
  "@adonisjs/session": "8.1.0",
86
91
  "@adonisjs/shield": "9.0.0",