@dudousxd/adonis-authkit-server 0.6.0 → 0.8.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.
Files changed (28) hide show
  1. package/build/host/views/account/security.edge +14 -1
  2. package/build/index.d.ts +14 -2
  3. package/build/index.js +9 -1
  4. package/build/src/define_config.d.ts +54 -0
  5. package/build/src/define_config.js +17 -0
  6. package/build/src/host/admin_api/admin_api_guard.d.ts +8 -0
  7. package/build/src/host/admin_api/admin_api_guard.js +41 -0
  8. package/build/src/host/admin_api/admin_users_service.d.ts +59 -0
  9. package/build/src/host/admin_api/admin_users_service.js +112 -0
  10. package/build/src/host/admin_api/api_clients_controller.d.ts +48 -0
  11. package/build/src/host/admin_api/api_clients_controller.js +104 -0
  12. package/build/src/host/admin_api/api_misc_controller.d.ts +17 -0
  13. package/build/src/host/admin_api/api_misc_controller.js +37 -0
  14. package/build/src/host/admin_api/api_users_controller.d.ts +78 -0
  15. package/build/src/host/admin_api/api_users_controller.js +135 -0
  16. package/build/src/host/admin_api/dto.d.ts +57 -0
  17. package/build/src/host/admin_api/dto.js +62 -0
  18. package/build/src/host/admin_api/token_verify_service.d.ts +34 -0
  19. package/build/src/host/admin_api/token_verify_service.js +74 -0
  20. package/build/src/host/avatar_storage.d.ts +58 -0
  21. package/build/src/host/avatar_storage.js +125 -0
  22. package/build/src/host/controllers/account_security_controller.js +33 -1
  23. package/build/src/host/controllers/admin/admin_users_controller.js +7 -58
  24. package/build/src/host/i18n.d.ts +8 -0
  25. package/build/src/host/i18n.js +8 -0
  26. package/build/src/host/register_auth_host.d.ts +7 -0
  27. package/build/src/host/register_auth_host.js +39 -0
  28. package/package.json +6 -1
@@ -0,0 +1,78 @@
1
+ import '../augmentations.js';
2
+ import type { HttpContext } from '@adonisjs/core/http';
3
+ /**
4
+ * Recurso de usuários da Admin REST API (R6). JSON puro (camelCase), erros no
5
+ * envelope `{ error: { code, message } }`. Toda escrita audita com `actor: 'admin-api'`.
6
+ */
7
+ export default class ApiUsersController {
8
+ #private;
9
+ /** GET /users — listagem paginada com busca por e-mail. */
10
+ index(ctx: HttpContext): Promise<{
11
+ data: any[];
12
+ total: any;
13
+ page: number;
14
+ limit: number;
15
+ }>;
16
+ /** GET /users/:id */
17
+ show(ctx: HttpContext): Promise<void | {
18
+ id: string;
19
+ email: string;
20
+ name: string | null;
21
+ avatarUrl: string | null;
22
+ globalRoles: string[];
23
+ disabled: boolean;
24
+ }>;
25
+ /** POST /users — { email, name?, password? | invite?:true }. */
26
+ store(ctx: HttpContext): Promise<void | {
27
+ invited: boolean;
28
+ id: string;
29
+ email: string;
30
+ name: string | null;
31
+ avatarUrl: string | null;
32
+ globalRoles: string[];
33
+ disabled: boolean;
34
+ }>;
35
+ /** PATCH /users/:id — { globalRoles?, name?, avatarUrl? }. */
36
+ update(ctx: HttpContext): Promise<void | {
37
+ id: string;
38
+ email: string;
39
+ name: string | null;
40
+ avatarUrl: string | null;
41
+ globalRoles: string[];
42
+ disabled: boolean;
43
+ }>;
44
+ /** POST /users/:id/disable */
45
+ disable(ctx: HttpContext): Promise<void | {
46
+ id: any;
47
+ disabled: boolean;
48
+ }>;
49
+ /** POST /users/:id/enable */
50
+ enable(ctx: HttpContext): Promise<void | {
51
+ id: any;
52
+ disabled: boolean;
53
+ }>;
54
+ /** POST /users/:id/reset-password — envia o e-mail de reset. */
55
+ resetPassword(ctx: HttpContext): Promise<void | {
56
+ id: any;
57
+ sent: boolean;
58
+ }>;
59
+ /** GET /users/:id/sessions — sessões + grants ativos. */
60
+ sessions(ctx: HttpContext): Promise<{
61
+ canList: boolean;
62
+ sessions: {
63
+ id: string;
64
+ accountId: string;
65
+ loginTs: number | null;
66
+ amr: string[];
67
+ }[];
68
+ grants: {
69
+ id: string;
70
+ accountId: string;
71
+ clientId: string | null;
72
+ accessTokens: number;
73
+ refreshTokens: number;
74
+ }[];
75
+ }>;
76
+ /** POST /users/:id/revoke-sessions — revoga todas as sessões/grants. */
77
+ revokeSessions(ctx: HttpContext): Promise<import("../admin_sessions_service.js").RevokeResult>;
78
+ }
@@ -0,0 +1,135 @@
1
+ import '../augmentations.js';
2
+ import { AdminUsersService } from './admin_users_service.js';
3
+ import { AdminSessionsService } from '../admin_sessions_service.js';
4
+ import { userDto, sessionDto, grantDto, apiError } from './dto.js';
5
+ const PAGE_SIZE = 20;
6
+ /** Lê a config + monta o actor `admin-api` para auditoria. */
7
+ async function ctxBits(ctx) {
8
+ const service = await ctx.containerResolver.make('authkit.server');
9
+ const cfg = service.config;
10
+ const actor = { actorId: null, ip: ctx.request.ip?.() ?? null, source: 'admin-api' };
11
+ return { service, cfg, actor };
12
+ }
13
+ /**
14
+ * Recurso de usuários da Admin REST API (R6). JSON puro (camelCase), erros no
15
+ * envelope `{ error: { code, message } }`. Toda escrita audita com `actor: 'admin-api'`.
16
+ */
17
+ export default class ApiUsersController {
18
+ /** GET /users — listagem paginada com busca por e-mail. */
19
+ async index(ctx) {
20
+ const { cfg } = await ctxBits(ctx);
21
+ const search = ctx.request.input('search', '').trim();
22
+ const page = Math.max(1, Number.parseInt(ctx.request.input('page', '1'), 10) || 1);
23
+ const limit = Math.max(1, Math.min(100, Number.parseInt(ctx.request.input('limit', String(PAGE_SIZE)), 10) || PAGE_SIZE));
24
+ const result = await cfg.accountStore.listAccounts({ search, page, limit });
25
+ const users = new AdminUsersService(cfg);
26
+ const data = await Promise.all(result.data.map(async (u) => userDto(u, await users.isDisabled(u.id))));
27
+ return { data, total: result.total, page, limit };
28
+ }
29
+ /** GET /users/:id */
30
+ async show(ctx) {
31
+ const { cfg } = await ctxBits(ctx);
32
+ const id = ctx.request.param('id');
33
+ const account = await cfg.accountStore.findById(id);
34
+ if (!account)
35
+ return ctx.response.notFound(apiError('not_found', 'Usuário não encontrado.'));
36
+ const disabled = await new AdminUsersService(cfg).isDisabled(id);
37
+ return userDto(account, disabled);
38
+ }
39
+ /** POST /users — { email, name?, password? | invite?:true }. */
40
+ async store(ctx) {
41
+ const { cfg, actor } = await ctxBits(ctx);
42
+ const email = ctx.request.input('email', '').trim();
43
+ const name = ctx.request.input('name') ?? null;
44
+ const password = ctx.request.input('password') ?? null;
45
+ const invite = ctx.request.input('invite') === true || ctx.request.input('invite') === 'true';
46
+ if (!email)
47
+ return ctx.response.badRequest(apiError('invalid_request', 'O campo email é obrigatório.'));
48
+ const users = new AdminUsersService(cfg);
49
+ const result = await users.create(ctx, { email, name, password, invite }, actor);
50
+ if (!result.ok) {
51
+ return ctx.response.conflict(apiError('email_taken', 'Já existe uma conta com este e-mail.'));
52
+ }
53
+ ctx.response.status(201);
54
+ return { ...userDto(result.account), invited: result.invited };
55
+ }
56
+ /** PATCH /users/:id — { globalRoles?, name?, avatarUrl? }. */
57
+ async update(ctx) {
58
+ const { cfg } = await ctxBits(ctx);
59
+ const id = ctx.request.param('id');
60
+ const account = await cfg.accountStore.findById(id);
61
+ if (!account)
62
+ return ctx.response.notFound(apiError('not_found', 'Usuário não encontrado.'));
63
+ const users = new AdminUsersService(cfg);
64
+ const roles = ctx.request.input('globalRoles');
65
+ if (Array.isArray(roles)) {
66
+ const normalized = Array.from(new Set(roles.map((r) => String(r).trim()).filter(Boolean)));
67
+ await users.setGlobalRoles(id, normalized);
68
+ }
69
+ const name = ctx.request.input('name');
70
+ const avatarUrl = ctx.request.input('avatarUrl');
71
+ if (name !== undefined || avatarUrl !== undefined) {
72
+ const patch = {};
73
+ if (name !== undefined)
74
+ patch.name = name;
75
+ if (avatarUrl !== undefined)
76
+ patch.avatarUrl = avatarUrl;
77
+ await users.updateProfile(id, patch);
78
+ }
79
+ const updated = await cfg.accountStore.findById(id);
80
+ return userDto(updated, await users.isDisabled(id));
81
+ }
82
+ /** POST /users/:id/disable */
83
+ async disable(ctx) {
84
+ return this.#setStatus(ctx, true);
85
+ }
86
+ /** POST /users/:id/enable */
87
+ async enable(ctx) {
88
+ return this.#setStatus(ctx, false);
89
+ }
90
+ async #setStatus(ctx, disable) {
91
+ const { cfg, actor } = await ctxBits(ctx);
92
+ const id = ctx.request.param('id');
93
+ const applied = await new AdminUsersService(cfg).setStatus(id, disable, actor);
94
+ if (!applied) {
95
+ return ctx.response.conflict(apiError('capability_unsupported', 'O store de contas não suporta habilitar/desabilitar.'));
96
+ }
97
+ return { id, disabled: disable };
98
+ }
99
+ /** POST /users/:id/reset-password — envia o e-mail de reset. */
100
+ async resetPassword(ctx) {
101
+ const { cfg, actor } = await ctxBits(ctx);
102
+ const id = ctx.request.param('id');
103
+ const account = await new AdminUsersService(cfg).resetPassword(ctx, id, actor);
104
+ if (!account)
105
+ return ctx.response.notFound(apiError('not_found', 'Usuário não encontrado.'));
106
+ return { id, sent: true };
107
+ }
108
+ /** GET /users/:id/sessions — sessões + grants ativos. */
109
+ async sessions(ctx) {
110
+ const { service } = await ctxBits(ctx);
111
+ const id = ctx.request.param('id');
112
+ const admin = new AdminSessionsService(service);
113
+ const sessions = await admin.listSessions(id);
114
+ const grants = await admin.listGrants(id);
115
+ return {
116
+ canList: admin.canList,
117
+ sessions: sessions.map(sessionDto),
118
+ grants: grants.map(grantDto),
119
+ };
120
+ }
121
+ /** POST /users/:id/revoke-sessions — revoga todas as sessões/grants. */
122
+ async revokeSessions(ctx) {
123
+ const { service, cfg, actor } = await ctxBits(ctx);
124
+ const id = ctx.request.param('id');
125
+ const result = await new AdminSessionsService(service).revokeAll(id);
126
+ await cfg.audit?.record({
127
+ type: 'session.revoked_all',
128
+ accountId: id,
129
+ actorId: actor.actorId,
130
+ ip: actor.ip,
131
+ metadata: { actor: actor.source, ...result },
132
+ });
133
+ return result;
134
+ }
135
+ }
@@ -0,0 +1,57 @@
1
+ import type { AuthAccount } from '../../accounts/account_store.js';
2
+ import type { AdminClient, CreatedClient } from '../admin_clients_service.js';
3
+ import type { AdminSession, AdminGrant } from '../admin_sessions_service.js';
4
+ import type { StoredAuditEvent } from '../../audit/audit_sink.js';
5
+ /** Projeta uma conta para a forma JSON (camelCase) da Admin REST API. */
6
+ export declare function userDto(account: AuthAccount, disabled?: boolean): {
7
+ id: string;
8
+ email: string;
9
+ name: string | null;
10
+ avatarUrl: string | null;
11
+ globalRoles: string[];
12
+ disabled: boolean;
13
+ };
14
+ export declare function clientDto(client: AdminClient): {
15
+ clientId: string;
16
+ confidential: boolean;
17
+ grants: string[];
18
+ redirectUris: string[];
19
+ postLogoutRedirectUris: string[];
20
+ tokenEndpointAuthMethod: string;
21
+ };
22
+ /** Inclui o secret (mostrado UMA vez) em create/regenerate. */
23
+ export declare function createdClientDto(created: CreatedClient): {
24
+ clientId: string;
25
+ clientSecret: string | null;
26
+ };
27
+ export declare function sessionDto(session: AdminSession): {
28
+ id: string;
29
+ accountId: string;
30
+ loginTs: number | null;
31
+ amr: string[];
32
+ };
33
+ export declare function grantDto(grant: AdminGrant): {
34
+ id: string;
35
+ accountId: string;
36
+ clientId: string | null;
37
+ accessTokens: number;
38
+ refreshTokens: number;
39
+ };
40
+ export declare function auditDto(event: StoredAuditEvent): {
41
+ id: string;
42
+ type: import("../../audit/audit_sink.js").AuditEventType;
43
+ accountId: string | null;
44
+ email: string | null;
45
+ clientId: string | null;
46
+ actorId: string | null;
47
+ ip: string | null;
48
+ metadata: Record<string, unknown> | null;
49
+ createdAt: string | null;
50
+ };
51
+ /** Envelope de erro padrão da Admin REST API. */
52
+ export declare function apiError(code: string, message: string): {
53
+ error: {
54
+ code: string;
55
+ message: string;
56
+ };
57
+ };
@@ -0,0 +1,62 @@
1
+ /** Projeta uma conta para a forma JSON (camelCase) da Admin REST API. */
2
+ export function userDto(account, disabled = false) {
3
+ return {
4
+ id: account.id,
5
+ email: account.email,
6
+ name: account.name ?? null,
7
+ avatarUrl: account.avatarUrl ?? null,
8
+ globalRoles: account.globalRoles ?? [],
9
+ disabled,
10
+ };
11
+ }
12
+ export function clientDto(client) {
13
+ return {
14
+ clientId: client.clientId,
15
+ confidential: client.confidential,
16
+ grants: client.grants,
17
+ redirectUris: client.redirectUris,
18
+ postLogoutRedirectUris: client.postLogoutRedirectUris,
19
+ tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
20
+ };
21
+ }
22
+ /** Inclui o secret (mostrado UMA vez) em create/regenerate. */
23
+ export function createdClientDto(created) {
24
+ return {
25
+ clientId: created.clientId,
26
+ clientSecret: created.clientSecret ?? null,
27
+ };
28
+ }
29
+ export function sessionDto(session) {
30
+ return {
31
+ id: session.id,
32
+ accountId: session.accountId,
33
+ loginTs: session.loginTs ?? null,
34
+ amr: session.amr ?? [],
35
+ };
36
+ }
37
+ export function grantDto(grant) {
38
+ return {
39
+ id: grant.id,
40
+ accountId: grant.accountId,
41
+ clientId: grant.clientId ?? null,
42
+ accessTokens: grant.accessTokens,
43
+ refreshTokens: grant.refreshTokens,
44
+ };
45
+ }
46
+ export function auditDto(event) {
47
+ return {
48
+ id: event.id,
49
+ type: event.type,
50
+ accountId: event.accountId ?? null,
51
+ email: event.email ?? null,
52
+ clientId: event.clientId ?? null,
53
+ actorId: event.actorId ?? null,
54
+ ip: event.ip ?? null,
55
+ metadata: event.metadata ?? null,
56
+ createdAt: event.createdAt instanceof Date ? event.createdAt.toISOString() : event.createdAt,
57
+ };
58
+ }
59
+ /** Envelope de erro padrão da Admin REST API. */
60
+ export function apiError(code, message) {
61
+ return { error: { code, message } };
62
+ }
@@ -0,0 +1,34 @@
1
+ import type { ResolvedServerConfig } from '../../define_config.js';
2
+ /** Resultado de introspecção genérica (PAT ou opaque access token). */
3
+ export type VerifyResult = {
4
+ active: false;
5
+ } | {
6
+ active: true;
7
+ /** 'pat' (Personal Access Token) ou 'access_token' (opaque AT do provider). */
8
+ tokenType: 'pat' | 'access_token';
9
+ sub: string;
10
+ email?: string | null;
11
+ name?: string | null;
12
+ roles?: string[];
13
+ scopes?: string[];
14
+ audience?: string | string[] | null;
15
+ clientId?: string | null;
16
+ exp?: number | null;
17
+ };
18
+ /**
19
+ * Introspecção genérica de token usada pela Admin REST API (`POST /tokens/verify`).
20
+ * Roteia por prefixo: tokens `pat_...` vão pelo {@link PatStore} (mesma rota do
21
+ * `/authkit/pat/introspect`); os demais são tratados como opaque access tokens e
22
+ * resolvidos pelo `AccessToken.find` do oidc-provider. Sempre best-effort: token
23
+ * desconhecido/expirado → `{ active: false }`.
24
+ */
25
+ export declare class TokenVerifyService {
26
+ #private;
27
+ private cfg;
28
+ /** Provider do oidc-provider (service.provider) — para opaque AT. */
29
+ private provider;
30
+ constructor(cfg: ResolvedServerConfig,
31
+ /** Provider do oidc-provider (service.provider) — para opaque AT. */
32
+ provider: any);
33
+ verify(token: string): Promise<VerifyResult>;
34
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Introspecção genérica de token usada pela Admin REST API (`POST /tokens/verify`).
3
+ * Roteia por prefixo: tokens `pat_...` vão pelo {@link PatStore} (mesma rota do
4
+ * `/authkit/pat/introspect`); os demais são tratados como opaque access tokens e
5
+ * resolvidos pelo `AccessToken.find` do oidc-provider. Sempre best-effort: token
6
+ * desconhecido/expirado → `{ active: false }`.
7
+ */
8
+ export class TokenVerifyService {
9
+ cfg;
10
+ provider;
11
+ constructor(cfg,
12
+ /** Provider do oidc-provider (service.provider) — para opaque AT. */
13
+ provider) {
14
+ this.cfg = cfg;
15
+ this.provider = provider;
16
+ }
17
+ async verify(token) {
18
+ if (!token || typeof token !== 'string')
19
+ return { active: false };
20
+ if (token.startsWith('pat_'))
21
+ return this.#verifyPat(token);
22
+ return this.#verifyAccessToken(token);
23
+ }
24
+ async #verifyPat(token) {
25
+ if (!this.cfg.patStore)
26
+ return { active: false };
27
+ const meta = await this.cfg.patStore.findActiveByToken(token);
28
+ if (!meta)
29
+ return { active: false };
30
+ const account = await this.cfg.accountStore.findById(meta.accountId);
31
+ if (!account)
32
+ return { active: false };
33
+ return {
34
+ active: true,
35
+ tokenType: 'pat',
36
+ sub: account.id,
37
+ email: account.email,
38
+ name: account.name ?? null,
39
+ roles: account.globalRoles ?? [],
40
+ scopes: meta.scopes,
41
+ audience: meta.audience,
42
+ exp: meta.exp,
43
+ };
44
+ }
45
+ async #verifyAccessToken(token) {
46
+ let at;
47
+ try {
48
+ at = await this.provider?.AccessToken?.find(token);
49
+ }
50
+ catch {
51
+ return { active: false };
52
+ }
53
+ if (!at)
54
+ return { active: false };
55
+ // O oidc-provider expira artefatos sozinho; isExpired/exp como guarda extra.
56
+ if (typeof at.isExpired === 'boolean' && at.isExpired)
57
+ return { active: false };
58
+ const sub = at.accountId ?? '';
59
+ const account = sub ? await this.cfg.accountStore.findById(sub) : null;
60
+ const scopes = typeof at.scope === 'string' ? at.scope.split(' ').filter(Boolean) : [];
61
+ return {
62
+ active: true,
63
+ tokenType: 'access_token',
64
+ sub,
65
+ email: account?.email ?? null,
66
+ name: account?.name ?? null,
67
+ roles: account?.globalRoles ?? [],
68
+ scopes,
69
+ audience: at.aud ?? null,
70
+ clientId: at.clientId ?? null,
71
+ exp: typeof at.exp === 'number' ? at.exp : null,
72
+ };
73
+ }
74
+ }
@@ -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
+ }