@adonis-agora/authkit-server 0.43.0 → 0.45.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/src/define_config.d.ts +20 -3
- package/build/src/define_config.js +3 -0
- package/build/src/host/account_api/account_api_controller.js +2 -2
- package/build/src/host/account_deletion_ops.d.ts +2 -2
- package/build/src/host/account_deletion_ops.js +3 -3
- package/build/src/host/account_deletion_service.js +1 -1
- package/build/src/host/account_roles.d.ts +26 -0
- package/build/src/host/account_roles.js +18 -0
- package/build/src/host/admin_console/admin_shell_controller.js +2 -1
- package/build/src/host/avatar_storage.d.ts +40 -14
- package/build/src/host/avatar_storage.js +221 -72
- package/build/src/host/controllers/account_security_controller.js +3 -3
- package/build/src/host/durable/account_deletion_workflow.js +1 -1
- package/build/src/host/register_auth_host.js +5 -1
- package/package.json +5 -1
|
@@ -249,19 +249,36 @@ export declare function resolveDeviceFlow(input?: DeviceFlowConfigInput): Resolv
|
|
|
249
249
|
*/
|
|
250
250
|
export interface UploadsConfigInput {
|
|
251
251
|
avatars?: {
|
|
252
|
-
/**
|
|
252
|
+
/**
|
|
253
|
+
* Backend de storage do avatar. Default: `'auto'` (media se
|
|
254
|
+
* `@adonis-agora/media` estiver presente/configurado, senão o drive builtin).
|
|
255
|
+
* `'builtin'` força o `@adonisjs/drive`; `'media'` força o media (degrada para
|
|
256
|
+
* o input de URL se indisponível).
|
|
257
|
+
*/
|
|
258
|
+
storage?: "auto" | "builtin" | "media";
|
|
259
|
+
/** Disk do `@adonisjs/drive` a usar (backend builtin). Default: o disk DEFAULT do app. */
|
|
253
260
|
disk?: string;
|
|
254
|
-
/** Diretório/prefixo das chaves. Default: 'authkit/avatars'. */
|
|
261
|
+
/** Diretório/prefixo das chaves (backend builtin). Default: 'authkit/avatars'. */
|
|
255
262
|
directory?: string;
|
|
263
|
+
/** Collection single-file do media (backend media). Default: 'avatar'. */
|
|
264
|
+
collection?: string;
|
|
265
|
+
/** ownerType do media (backend media). Default: 'AuthAccount'. */
|
|
266
|
+
ownerType?: string;
|
|
256
267
|
/** Tamanho máximo em MB. Default: 5. */
|
|
257
268
|
maxSizeMb?: number;
|
|
258
269
|
};
|
|
259
270
|
}
|
|
260
271
|
export interface ResolvedUploadsConfig {
|
|
261
272
|
avatars: {
|
|
262
|
-
/**
|
|
273
|
+
/** Backend ativo (default 'auto'). */
|
|
274
|
+
storage: "auto" | "builtin" | "media";
|
|
275
|
+
/** Disk explícito; `undefined` = disk DEFAULT do app (backend builtin). */
|
|
263
276
|
disk?: string;
|
|
264
277
|
directory: string;
|
|
278
|
+
/** Collection single-file do media (backend media). */
|
|
279
|
+
collection: string;
|
|
280
|
+
/** ownerType do media (backend media). */
|
|
281
|
+
ownerType: string;
|
|
265
282
|
maxSizeMb: number;
|
|
266
283
|
};
|
|
267
284
|
}
|
|
@@ -67,8 +67,11 @@ export function resolveDeviceFlow(input) {
|
|
|
67
67
|
export function resolveUploads(input) {
|
|
68
68
|
return {
|
|
69
69
|
avatars: {
|
|
70
|
+
storage: input?.avatars?.storage ?? "auto",
|
|
70
71
|
disk: input?.avatars?.disk,
|
|
71
72
|
directory: input?.avatars?.directory ?? "authkit/avatars",
|
|
73
|
+
collection: input?.avatars?.collection ?? "avatar",
|
|
74
|
+
ownerType: input?.avatars?.ownerType ?? "AuthAccount",
|
|
72
75
|
maxSizeMb: input?.avatars?.maxSizeMb ?? 5,
|
|
73
76
|
},
|
|
74
77
|
};
|
|
@@ -40,7 +40,7 @@ import { resolveEffectiveEmailChange, resolveEffectivePasswordHistory, } from '.
|
|
|
40
40
|
import { dispatchSecurityNotice } from '../security_notice_service.js';
|
|
41
41
|
import { requireSudo, isSudoActive, SUDO_MODE_DEFAULTS, resolveEffectiveSudoMode } from '../sudo_mode.js';
|
|
42
42
|
import { translate } from '../i18n.js';
|
|
43
|
-
import { storeAvatar,
|
|
43
|
+
import { storeAvatar, isAvatarUploadSupported, AvatarUploadError } from '../avatar_storage.js';
|
|
44
44
|
import { sendEmailChangeConfirmationEmail, sendEmailChangeNoticeEmail, } from '../default_mailer.js';
|
|
45
45
|
import { ACTIVE_ORG_COOKIE } from '../active_org_cookie.js';
|
|
46
46
|
// ---------------------------------------------------------------------------
|
|
@@ -138,7 +138,7 @@ export default class AccountApiController {
|
|
|
138
138
|
passkeysSupported: supportsPasskeys(cfg.accountStore),
|
|
139
139
|
orgsSupported: supportsOrganizations(cfg.accountStore),
|
|
140
140
|
tokensSupported: !!cfg.patStore,
|
|
141
|
-
avatarUploadSupported: await
|
|
141
|
+
avatarUploadSupported: await isAvatarUploadSupported(cfg.uploads),
|
|
142
142
|
sessionsSupported: new AdminSessionsService(service).canList,
|
|
143
143
|
},
|
|
144
144
|
};
|
|
@@ -61,8 +61,8 @@ export declare function removeFromOrgs(cfg: ResolvedServerConfig, accountId: str
|
|
|
61
61
|
orgMemberships: number;
|
|
62
62
|
orgInvitations: number;
|
|
63
63
|
}>;
|
|
64
|
-
/** 7) Apaga o avatar no drive
|
|
65
|
-
export declare function deleteAccountAvatar(cfg: ResolvedServerConfig, avatarUrl: string | null): Promise<{
|
|
64
|
+
/** 7) Apaga o avatar no backend ativo (drive OU media; best-effort, fail-safe). */
|
|
65
|
+
export declare function deleteAccountAvatar(cfg: ResolvedServerConfig, accountId: string, avatarUrl: string | null): Promise<{
|
|
66
66
|
avatarDeleted: boolean;
|
|
67
67
|
}>;
|
|
68
68
|
/** 8) Anonimiza o histórico de audit da conta (quando o sink suporta). */
|
|
@@ -92,9 +92,9 @@ export async function removeFromOrgs(cfg, accountId) {
|
|
|
92
92
|
orgInvitations: orgResult.invitations,
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
|
-
/** 7) Apaga o avatar no drive
|
|
96
|
-
export async function deleteAccountAvatar(cfg, avatarUrl) {
|
|
97
|
-
const avatarDeleted = await deleteAvatar(cfg.uploads, avatarUrl);
|
|
95
|
+
/** 7) Apaga o avatar no backend ativo (drive OU media; best-effort, fail-safe). */
|
|
96
|
+
export async function deleteAccountAvatar(cfg, accountId, avatarUrl) {
|
|
97
|
+
const avatarDeleted = await deleteAvatar(cfg.uploads, accountId, avatarUrl);
|
|
98
98
|
return { avatarDeleted };
|
|
99
99
|
}
|
|
100
100
|
/** 8) Anonimiza o histórico de audit da conta (quando o sink suporta). */
|
|
@@ -114,7 +114,7 @@ export class AccountDeletionService {
|
|
|
114
114
|
}
|
|
115
115
|
// 7) Avatar no drive (best-effort, fail-safe).
|
|
116
116
|
try {
|
|
117
|
-
result.avatarDeleted = (await deleteAccountAvatar(cfg, snapshot.avatarUrl)).avatarDeleted;
|
|
117
|
+
result.avatarDeleted = (await deleteAccountAvatar(cfg, accountId, snapshot.avatarUrl)).avatarDeleted;
|
|
118
118
|
}
|
|
119
119
|
catch {
|
|
120
120
|
/* best-effort */
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { AuthAccount } from '../accounts/account_store.js';
|
|
2
|
+
/** The slice of the resolved config the role resolution needs (structurally typed). */
|
|
3
|
+
type RoleResolverConfig = {
|
|
4
|
+
resolveTokenRoles?: (account: AuthAccount, context: {
|
|
5
|
+
clientId?: string;
|
|
6
|
+
activeOrg?: {
|
|
7
|
+
orgId: string;
|
|
8
|
+
orgSlug: string;
|
|
9
|
+
orgRole: string;
|
|
10
|
+
} | null;
|
|
11
|
+
}) => string[] | Promise<string[]>;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* An account's effective roles for host-side gating (the admin console).
|
|
15
|
+
*
|
|
16
|
+
* When the host configures `resolveTokenRoles`, that hook is the single source of an account's roles
|
|
17
|
+
* — the same one the OIDC `roles` claim is minted from — so console access tracks the host's role
|
|
18
|
+
* authority (e.g. an app that keeps roles in its own table via `@adonis-agora/authz`) instead of the
|
|
19
|
+
* account's stored `globalRoles`. Falls back to `account.globalRoles ?? []` when no hook is set, so
|
|
20
|
+
* default behavior is unchanged.
|
|
21
|
+
*
|
|
22
|
+
* The console is not OIDC-client- or org-scoped for role purposes, so the hook is called with an
|
|
23
|
+
* empty context (`clientId: undefined`, `activeOrg: null`).
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveAccountRoles(cfg: RoleResolverConfig, account: AuthAccount): Promise<string[]>;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An account's effective roles for host-side gating (the admin console).
|
|
3
|
+
*
|
|
4
|
+
* When the host configures `resolveTokenRoles`, that hook is the single source of an account's roles
|
|
5
|
+
* — the same one the OIDC `roles` claim is minted from — so console access tracks the host's role
|
|
6
|
+
* authority (e.g. an app that keeps roles in its own table via `@adonis-agora/authz`) instead of the
|
|
7
|
+
* account's stored `globalRoles`. Falls back to `account.globalRoles ?? []` when no hook is set, so
|
|
8
|
+
* default behavior is unchanged.
|
|
9
|
+
*
|
|
10
|
+
* The console is not OIDC-client- or org-scoped for role purposes, so the hook is called with an
|
|
11
|
+
* empty context (`clientId: undefined`, `activeOrg: null`).
|
|
12
|
+
*/
|
|
13
|
+
export async function resolveAccountRoles(cfg, account) {
|
|
14
|
+
if (cfg.resolveTokenRoles) {
|
|
15
|
+
return cfg.resolveTokenRoles(account, { clientId: undefined, activeOrg: null });
|
|
16
|
+
}
|
|
17
|
+
return account.globalRoles ?? [];
|
|
18
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { extname } from 'node:path';
|
|
3
3
|
import { getAdminPrefix } from '../admin_prefix.js';
|
|
4
|
+
import { resolveAccountRoles } from '../account_roles.js';
|
|
4
5
|
// ─── Shell HTML cache ─────────────────────────────────────────────────────────
|
|
5
6
|
/**
|
|
6
7
|
* Points to the Vite-built index.html inside build/host/ui-dist/.
|
|
@@ -120,7 +121,7 @@ export default class AdminShellController {
|
|
|
120
121
|
currentUser = {
|
|
121
122
|
id: account.id,
|
|
122
123
|
email: account.email,
|
|
123
|
-
roles:
|
|
124
|
+
roles: await resolveAccountRoles(cfg, account),
|
|
124
125
|
};
|
|
125
126
|
}
|
|
126
127
|
}
|
|
@@ -20,6 +20,18 @@ type DriveService = any;
|
|
|
20
20
|
* @internal
|
|
21
21
|
*/
|
|
22
22
|
export declare function __setDriveLoaderForTests(fn: (() => Promise<DriveService | null>) | undefined): void;
|
|
23
|
+
/**
|
|
24
|
+
* Módulo `@adonis-agora/media/single-file` resolvido de forma preguiçosa. Tipado
|
|
25
|
+
* como `any` de propósito: a lib NÃO depende do media em tempo de compilação
|
|
26
|
+
* (peer/opt-in). Expõe `storeSingleFile`/`removeSingleFile`/`isSingleFileStoreAvailable`.
|
|
27
|
+
*/
|
|
28
|
+
type MediaModule = any;
|
|
29
|
+
/**
|
|
30
|
+
* Permite reapontar/limpar o loader do media (usado em testes). Espelha
|
|
31
|
+
* {@link __setDriveLoaderForTests}.
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
export declare function __setMediaLoaderForTests(fn: (() => Promise<MediaModule | null>) | undefined): void;
|
|
23
35
|
/** Erro de validação do upload (mensagem já localizada no controller). */
|
|
24
36
|
export declare class AvatarUploadError extends Error {
|
|
25
37
|
reason: 'extname' | 'size';
|
|
@@ -29,7 +41,9 @@ export declare class AvatarUploadError extends Error {
|
|
|
29
41
|
export interface UploadedAvatar {
|
|
30
42
|
extname?: string | null;
|
|
31
43
|
size?: number;
|
|
32
|
-
/**
|
|
44
|
+
/** MIME reportado pelo multipart (ex.: 'image/png'). Usado pelo backend media. */
|
|
45
|
+
type?: string | null;
|
|
46
|
+
/** Caminho temporário (drive lê daqui para stream; media lê os bytes daqui). */
|
|
33
47
|
tmpPath?: string | null;
|
|
34
48
|
/** Move o arquivo para um disk do drive (API v3+). */
|
|
35
49
|
moveToDisk?: (key: string, options?: {
|
|
@@ -42,27 +56,39 @@ export interface UploadedAvatar {
|
|
|
42
56
|
*/
|
|
43
57
|
export declare function isDriveAvailable(): Promise<boolean>;
|
|
44
58
|
/**
|
|
45
|
-
*
|
|
59
|
+
* Indica se o upload de avatar está disponível para a config dada — i.e. se ALGUM
|
|
60
|
+
* backend configurado consegue armazenar (mesma lógica de seleção do
|
|
61
|
+
* {@link resolveUploader}: `'builtin'` → drive; `'media'` → media; `'auto'` →
|
|
62
|
+
* media OU drive). Usado pelas views/controllers para decidir mostrar o input de
|
|
63
|
+
* arquivo. Best-effort: nunca lança.
|
|
64
|
+
*
|
|
65
|
+
* Substitui o antigo gate por {@link isDriveAvailable}, que escondia o input num
|
|
66
|
+
* host media-only (media presente, drive ausente) mesmo com o media capaz de armazenar.
|
|
67
|
+
*/
|
|
68
|
+
export declare function isAvatarUploadSupported(cfg: ResolvedUploadsConfig): Promise<boolean>;
|
|
69
|
+
/**
|
|
70
|
+
* Armazena o avatar no backend ativo (drive OU media) e retorna a URL pública.
|
|
46
71
|
*
|
|
47
|
-
* -
|
|
72
|
+
* - Resolve o backend PRIMEIRO: se nenhum estiver disponível → retorna `null`
|
|
73
|
+
* (degrada para o input de URL, feature off) SEM validar/lançar. Isso preserva o
|
|
74
|
+
* comportamento histórico: um host sem backend nunca vê erro de validação.
|
|
75
|
+
* - Só quando há backend valida extensão (jpg/jpeg/png/webp) e tamanho
|
|
76
|
+
* (≤ maxSizeMb) — COMPARTILHADO, antes de entregar ao backend; lança
|
|
48
77
|
* {@link AvatarUploadError} se inválido (o controller traduz/flasha).
|
|
49
|
-
* -
|
|
50
|
-
* - Se o drive estiver ausente/não-configurado → retorna `null` (degrada para URL).
|
|
78
|
+
* - Backend conforme `cfg.avatars.storage` (ver {@link resolveUploader}).
|
|
51
79
|
*
|
|
52
|
-
* Nunca lança por causa de
|
|
80
|
+
* Nunca lança por causa de backend ausente; só lança em validação (com backend).
|
|
53
81
|
*/
|
|
54
|
-
export declare function storeAvatar(
|
|
82
|
+
export declare function storeAvatar(ctx: HttpContext, cfg: ResolvedUploadsConfig, file: UploadedAvatar, accountId: string, messages: {
|
|
55
83
|
extname: string;
|
|
56
84
|
size: string;
|
|
57
85
|
}): Promise<string | null>;
|
|
58
86
|
/**
|
|
59
|
-
* Deleta (best-effort, fail-safe) o avatar de uma conta no
|
|
60
|
-
*
|
|
61
|
-
*
|
|
87
|
+
* Deleta (best-effort, fail-safe) o avatar de uma conta no backend ativo. Usado
|
|
88
|
+
* pela deleção de conta (LGPD). O `accountId` é o owner — necessário para o backend
|
|
89
|
+
* media apagar por owner; o backend builtin deriva a key da `storedUrlOrKey`.
|
|
62
90
|
*
|
|
63
|
-
* NUNCA lança:
|
|
64
|
-
* derivada da `storedUrlOrKey` pegando o trecho a partir de `directory/` (cobre
|
|
65
|
-
* tanto uma key relativa quanto uma URL pública que contenha o caminho).
|
|
91
|
+
* NUNCA lança: backend ausente, key não reconhecida ou erro de I/O → no-op.
|
|
66
92
|
*/
|
|
67
|
-
export declare function deleteAvatar(cfg: ResolvedUploadsConfig, storedUrlOrKey: string | null | undefined): Promise<boolean>;
|
|
93
|
+
export declare function deleteAvatar(cfg: ResolvedUploadsConfig, accountId: string | null | undefined, storedUrlOrKey: string | null | undefined): Promise<boolean>;
|
|
68
94
|
export {};
|
|
@@ -34,8 +34,43 @@ export function __setDriveLoaderForTests(fn) {
|
|
|
34
34
|
driveServicePromise = undefined;
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
let mediaModulePromise;
|
|
38
|
+
/**
|
|
39
|
+
* Importa o helper single-file do `@adonis-agora/media` de forma preguiçosa e
|
|
40
|
+
* fail-safe. Espelha {@link loadDrive}: se o pacote não estiver instalado, resolve
|
|
41
|
+
* `null` (o specifier é indireto para não ser resolvido em build-time — peer opcional).
|
|
42
|
+
*/
|
|
43
|
+
async function loadMedia() {
|
|
44
|
+
if (!mediaModulePromise) {
|
|
45
|
+
const specifier = '@adonis-agora/media/single-file';
|
|
46
|
+
mediaModulePromise = import(__rewriteRelativeImportExtension(specifier))
|
|
47
|
+
.then((mod) => mod ?? null)
|
|
48
|
+
.catch(() => null);
|
|
49
|
+
}
|
|
50
|
+
return mediaModulePromise;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Permite reapontar/limpar o loader do media (usado em testes). Espelha
|
|
54
|
+
* {@link __setDriveLoaderForTests}.
|
|
55
|
+
* @internal
|
|
56
|
+
*/
|
|
57
|
+
export function __setMediaLoaderForTests(fn) {
|
|
58
|
+
if (fn) {
|
|
59
|
+
mediaModulePromise = fn();
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
mediaModulePromise = undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
37
65
|
/** Extensões aceitas para o avatar (imagem raster comum). */
|
|
38
66
|
const ALLOWED_EXTNAMES = ['jpg', 'jpeg', 'png', 'webp'];
|
|
67
|
+
/** MIME por extensão validada — fallback quando o file de multipart não traz `type`. */
|
|
68
|
+
const EXT_MIME = {
|
|
69
|
+
jpg: 'image/jpeg',
|
|
70
|
+
jpeg: 'image/jpeg',
|
|
71
|
+
png: 'image/png',
|
|
72
|
+
webp: 'image/webp',
|
|
73
|
+
};
|
|
39
74
|
/** Erro de validação do upload (mensagem já localizada no controller). */
|
|
40
75
|
export class AvatarUploadError extends Error {
|
|
41
76
|
reason;
|
|
@@ -76,91 +111,205 @@ function buildKey(cfg, accountId, ext) {
|
|
|
76
111
|
return `${cfg.avatars.directory}/${accountId}-${random}.${ext}`;
|
|
77
112
|
}
|
|
78
113
|
/**
|
|
79
|
-
*
|
|
114
|
+
* Backend builtin: o `@adonisjs/drive` JÁ configurado no app. Comportamento
|
|
115
|
+
* BYTE-IDÊNTICO ao histórico (moveToDisk/putStream + getUrl; delete por key
|
|
116
|
+
* derivada do diretório).
|
|
117
|
+
*/
|
|
118
|
+
const builtinUploader = {
|
|
119
|
+
async store(_ctx, cfg, file, accountId, ext) {
|
|
120
|
+
const drive = await loadDrive();
|
|
121
|
+
if (!drive)
|
|
122
|
+
return null;
|
|
123
|
+
// Resolve o disk: o configurado, ou o DEFAULT do drive do app.
|
|
124
|
+
let disk;
|
|
125
|
+
try {
|
|
126
|
+
disk = cfg.avatars.disk ? drive.use(cfg.avatars.disk) : drive.use();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// disk inválido/não-configurado — degrada para URL.
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
if (!disk)
|
|
133
|
+
return null;
|
|
134
|
+
const key = buildKey(cfg, accountId || 'account', ext);
|
|
135
|
+
// API @adonisjs/drive v3+: o file de multipart move-se direto para o disk.
|
|
136
|
+
// `moveToDisk` lê do tmpPath e usa o disk informado (ou o default da config).
|
|
137
|
+
if (typeof file.moveToDisk === 'function') {
|
|
138
|
+
await file.moveToDisk(key, cfg.avatars.disk ? { disk: cfg.avatars.disk } : undefined);
|
|
139
|
+
}
|
|
140
|
+
else if (file.tmpPath) {
|
|
141
|
+
// Fallback: lê do tmpPath e escreve via putStream no disk resolvido.
|
|
142
|
+
const fs = await import('node:fs');
|
|
143
|
+
await disk.putStream(key, fs.createReadStream(file.tmpPath));
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
return await disk.getUrl(key);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// disk sem getUrl público — retorna a key como referência relativa.
|
|
153
|
+
return key;
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
async delete(cfg, _accountId, storedUrlOrKey) {
|
|
157
|
+
if (!storedUrlOrKey)
|
|
158
|
+
return false;
|
|
159
|
+
const drive = await loadDrive();
|
|
160
|
+
if (!drive)
|
|
161
|
+
return false;
|
|
162
|
+
// Deriva a key: trecho a partir de `<directory>/`. Se não bater, aborta (não
|
|
163
|
+
// arriscamos deletar algo fora do nosso diretório).
|
|
164
|
+
const dir = cfg.avatars.directory.replace(/\/+$/, '');
|
|
165
|
+
const marker = `${dir}/`;
|
|
166
|
+
const idx = storedUrlOrKey.indexOf(marker);
|
|
167
|
+
if (idx < 0)
|
|
168
|
+
return false;
|
|
169
|
+
// Remove querystring/fragment de uma URL pública.
|
|
170
|
+
const key = storedUrlOrKey.slice(idx).split(/[?#]/)[0];
|
|
171
|
+
let disk;
|
|
172
|
+
try {
|
|
173
|
+
disk = cfg.avatars.disk ? drive.use(cfg.avatars.disk) : drive.use();
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
if (!disk || typeof disk.delete !== 'function')
|
|
179
|
+
return false;
|
|
180
|
+
try {
|
|
181
|
+
await disk.delete(key);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* Backend media: delega ao `@adonis-agora/media` (collection single-file). O
|
|
191
|
+
* lifecycle é keyed por owner (`ownerType`/`ownerId`) — o `single: true` da
|
|
192
|
+
* collection faz o replace de slot; a URL final é o que persistimos em `avatarUrl`.
|
|
193
|
+
*/
|
|
194
|
+
function makeMediaUploader(media) {
|
|
195
|
+
return {
|
|
196
|
+
async store(_ctx, cfg, file, accountId, ext) {
|
|
197
|
+
if (!file.tmpPath)
|
|
198
|
+
return null;
|
|
199
|
+
const fs = await import('node:fs/promises');
|
|
200
|
+
const contents = await fs.readFile(file.tmpPath);
|
|
201
|
+
const fileName = `avatar.${ext}`;
|
|
202
|
+
const mimeType = file.type ?? EXT_MIME[ext] ?? 'application/octet-stream';
|
|
203
|
+
const result = await media.storeSingleFile({
|
|
204
|
+
ownerType: cfg.avatars.ownerType,
|
|
205
|
+
ownerId: accountId,
|
|
206
|
+
collection: cfg.avatars.collection,
|
|
207
|
+
fileName,
|
|
208
|
+
mimeType,
|
|
209
|
+
contents,
|
|
210
|
+
});
|
|
211
|
+
return result?.url ?? null;
|
|
212
|
+
},
|
|
213
|
+
async delete(cfg, accountId, _storedUrlOrKey) {
|
|
214
|
+
// media apaga por owner (a key/URL não é usada — o lifecycle é do owner).
|
|
215
|
+
if (!accountId)
|
|
216
|
+
return false;
|
|
217
|
+
try {
|
|
218
|
+
await media.removeSingleFile({
|
|
219
|
+
ownerType: cfg.avatars.ownerType,
|
|
220
|
+
ownerId: accountId,
|
|
221
|
+
collection: cfg.avatars.collection,
|
|
222
|
+
});
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Resolve o backend de avatar conforme `cfg.avatars.storage`:
|
|
233
|
+
* - `'builtin'` → sempre o drive (ou `null` se ausente).
|
|
234
|
+
* - `'media'` → o media (ou `null` se ausente/indisponível — degrada gracioso).
|
|
235
|
+
* - `'auto'` → media se disponível, senão o drive.
|
|
80
236
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
|
|
84
|
-
|
|
237
|
+
* "media disponível" = pacote presente E `isSingleFileStoreAvailable()` true (o
|
|
238
|
+
* MediaManager está bindado no container do app). Nunca lança.
|
|
239
|
+
*/
|
|
240
|
+
async function resolveUploader(cfg) {
|
|
241
|
+
const storage = cfg.avatars.storage;
|
|
242
|
+
if (storage === 'builtin') {
|
|
243
|
+
return (await loadDrive()) ? builtinUploader : null;
|
|
244
|
+
}
|
|
245
|
+
if (storage === 'media') {
|
|
246
|
+
const media = await loadMediaIfUsable();
|
|
247
|
+
return media ? makeMediaUploader(media) : null;
|
|
248
|
+
}
|
|
249
|
+
// 'auto' (default): media se disponível, senão builtin.
|
|
250
|
+
const media = await loadMediaIfUsable();
|
|
251
|
+
if (media)
|
|
252
|
+
return makeMediaUploader(media);
|
|
253
|
+
return (await loadDrive()) ? builtinUploader : null;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Indica se o upload de avatar está disponível para a config dada — i.e. se ALGUM
|
|
257
|
+
* backend configurado consegue armazenar (mesma lógica de seleção do
|
|
258
|
+
* {@link resolveUploader}: `'builtin'` → drive; `'media'` → media; `'auto'` →
|
|
259
|
+
* media OU drive). Usado pelas views/controllers para decidir mostrar o input de
|
|
260
|
+
* arquivo. Best-effort: nunca lança.
|
|
85
261
|
*
|
|
86
|
-
*
|
|
262
|
+
* Substitui o antigo gate por {@link isDriveAvailable}, que escondia o input num
|
|
263
|
+
* host media-only (media presente, drive ausente) mesmo com o media capaz de armazenar.
|
|
87
264
|
*/
|
|
88
|
-
export async function
|
|
89
|
-
|
|
90
|
-
|
|
265
|
+
export async function isAvatarUploadSupported(cfg) {
|
|
266
|
+
return (await resolveUploader(cfg)) !== null;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Carrega o módulo media só se ele estiver USÁVEL: pacote presente E
|
|
270
|
+
* `isSingleFileStoreAvailable()` resolve `true`. Best-effort — qualquer erro → null.
|
|
271
|
+
*/
|
|
272
|
+
async function loadMediaIfUsable() {
|
|
273
|
+
const media = await loadMedia();
|
|
274
|
+
if (!media)
|
|
91
275
|
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
276
|
try {
|
|
96
|
-
|
|
277
|
+
return (await media.isSingleFileStoreAvailable()) ? media : null;
|
|
97
278
|
}
|
|
98
279
|
catch {
|
|
99
|
-
// disk inválido/não-configurado — degrada para URL.
|
|
100
280
|
return null;
|
|
101
281
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Armazena o avatar no backend ativo (drive OU media) e retorna a URL pública.
|
|
285
|
+
*
|
|
286
|
+
* - Resolve o backend PRIMEIRO: se nenhum estiver disponível → retorna `null`
|
|
287
|
+
* (degrada para o input de URL, feature off) SEM validar/lançar. Isso preserva o
|
|
288
|
+
* comportamento histórico: um host sem backend nunca vê erro de validação.
|
|
289
|
+
* - Só quando há backend valida extensão (jpg/jpeg/png/webp) e tamanho
|
|
290
|
+
* (≤ maxSizeMb) — COMPARTILHADO, antes de entregar ao backend; lança
|
|
291
|
+
* {@link AvatarUploadError} se inválido (o controller traduz/flasha).
|
|
292
|
+
* - Backend conforme `cfg.avatars.storage` (ver {@link resolveUploader}).
|
|
293
|
+
*
|
|
294
|
+
* Nunca lança por causa de backend ausente; só lança em validação (com backend).
|
|
295
|
+
*/
|
|
296
|
+
export async function storeAvatar(ctx, cfg, file, accountId, messages) {
|
|
297
|
+
const uploader = await resolveUploader(cfg);
|
|
298
|
+
if (!uploader)
|
|
116
299
|
return null;
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
}
|
|
300
|
+
const ext = validate(file, cfg, messages);
|
|
301
|
+
return uploader.store(ctx, cfg, file, accountId, ext);
|
|
125
302
|
}
|
|
126
303
|
/**
|
|
127
|
-
* Deleta (best-effort, fail-safe) o avatar de uma conta no
|
|
128
|
-
*
|
|
129
|
-
*
|
|
304
|
+
* Deleta (best-effort, fail-safe) o avatar de uma conta no backend ativo. Usado
|
|
305
|
+
* pela deleção de conta (LGPD). O `accountId` é o owner — necessário para o backend
|
|
306
|
+
* media apagar por owner; o backend builtin deriva a key da `storedUrlOrKey`.
|
|
130
307
|
*
|
|
131
|
-
* NUNCA lança:
|
|
132
|
-
* derivada da `storedUrlOrKey` pegando o trecho a partir de `directory/` (cobre
|
|
133
|
-
* tanto uma key relativa quanto uma URL pública que contenha o caminho).
|
|
308
|
+
* NUNCA lança: backend ausente, key não reconhecida ou erro de I/O → no-op.
|
|
134
309
|
*/
|
|
135
|
-
export async function deleteAvatar(cfg, storedUrlOrKey) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const drive = await loadDrive();
|
|
139
|
-
if (!drive)
|
|
140
|
-
return false;
|
|
141
|
-
// Deriva a key: trecho a partir de `<directory>/`. Se não bater, aborta (não
|
|
142
|
-
// arriscamos deletar algo fora do nosso diretório).
|
|
143
|
-
const dir = cfg.avatars.directory.replace(/\/+$/, '');
|
|
144
|
-
const marker = `${dir}/`;
|
|
145
|
-
const idx = storedUrlOrKey.indexOf(marker);
|
|
146
|
-
if (idx < 0)
|
|
147
|
-
return false;
|
|
148
|
-
// Remove querystring/fragment de uma URL pública.
|
|
149
|
-
const key = storedUrlOrKey.slice(idx).split(/[?#]/)[0];
|
|
150
|
-
let disk;
|
|
151
|
-
try {
|
|
152
|
-
disk = cfg.avatars.disk ? drive.use(cfg.avatars.disk) : drive.use();
|
|
153
|
-
}
|
|
154
|
-
catch {
|
|
310
|
+
export async function deleteAvatar(cfg, accountId, storedUrlOrKey) {
|
|
311
|
+
const uploader = await resolveUploader(cfg);
|
|
312
|
+
if (!uploader)
|
|
155
313
|
return false;
|
|
156
|
-
|
|
157
|
-
if (!disk || typeof disk.delete !== 'function')
|
|
158
|
-
return false;
|
|
159
|
-
try {
|
|
160
|
-
await disk.delete(key);
|
|
161
|
-
return true;
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
return false;
|
|
165
|
-
}
|
|
314
|
+
return uploader.delete(cfg, accountId, storedUrlOrKey);
|
|
166
315
|
}
|
|
@@ -3,7 +3,7 @@ import { ACCOUNT_SESSION_KEY } from "../middleware/account_auth.js";
|
|
|
3
3
|
import { supportsAccountSecurity, supportsAccountDeletion, supportsProfile, supportsPasswordHistory, } from "../../accounts/account_store.js";
|
|
4
4
|
import { changePasswordValidator, changeEmailValidator, deleteAccountValidator, updateProfileValidator, } from "../validators.js";
|
|
5
5
|
import { sendEmailChangeConfirmationEmail, sendEmailChangeNoticeEmail, sendEmailChangedCompletedEmail, } from "../default_mailer.js";
|
|
6
|
-
import { storeAvatar,
|
|
6
|
+
import { storeAvatar, isAvatarUploadSupported, AvatarUploadError, } from "../avatar_storage.js";
|
|
7
7
|
import { translate } from "../i18n.js";
|
|
8
8
|
import { TRUSTED_DEVICE_COOKIE } from "../trusted_device.js";
|
|
9
9
|
import { AccountDeletionService } from "../account_deletion_service.js";
|
|
@@ -67,8 +67,8 @@ export default class AccountSecurityController {
|
|
|
67
67
|
csrfToken: ctx.request.csrfToken,
|
|
68
68
|
supported: supportsAccountSecurity(cfg.accountStore),
|
|
69
69
|
profileSupported: supportsProfile(cfg.accountStore),
|
|
70
|
-
// Só mostramos o input de arquivo se
|
|
71
|
-
avatarUploadSupported: await
|
|
70
|
+
// Só mostramos o input de arquivo se ALGUM backend (drive OU media) puder armazenar.
|
|
71
|
+
avatarUploadSupported: await isAvatarUploadSupported(cfg.uploads),
|
|
72
72
|
email: account?.email ?? "",
|
|
73
73
|
name: account?.name ?? "",
|
|
74
74
|
avatarUrl: account?.avatarUrl ?? "",
|
|
@@ -75,7 +75,7 @@ export function defineAccountDeletionWorkflow(deps) {
|
|
|
75
75
|
result.orgMemberships = orgResult.orgMemberships;
|
|
76
76
|
result.orgInvitations = orgResult.orgInvitations;
|
|
77
77
|
// 7) Avatar no drive.
|
|
78
|
-
result.avatarDeleted = (await ctx.step("delete.avatar", async () => deleteAccountAvatar((await deps.oidc()).config, snapshot.avatarUrl))).avatarDeleted;
|
|
78
|
+
result.avatarDeleted = (await ctx.step("delete.avatar", async () => deleteAccountAvatar((await deps.oidc()).config, accountId, snapshot.avatarUrl))).avatarDeleted;
|
|
79
79
|
// 8) Anonimiza o histórico de audit.
|
|
80
80
|
result.auditAnonymized = (await ctx.step("anonymize.audit", async () => anonymizeAudit((await deps.oidc()).config, accountId))).auditAnonymized;
|
|
81
81
|
// 9) Deleta a linha da conta (ÚLTIMA etapa, forward-only).
|
|
@@ -2,6 +2,7 @@ import { resolveRateLimit } from '../define_config.js';
|
|
|
2
2
|
import { createAuthThrottles } from './rate_limit.js';
|
|
3
3
|
import { ACCOUNT_SESSION_KEY } from './middleware/account_auth.js';
|
|
4
4
|
import { accountHome } from './account_home.js';
|
|
5
|
+
import { resolveAccountRoles } from './account_roles.js';
|
|
5
6
|
import { adminApiGuard } from './admin_api/admin_api_guard.js';
|
|
6
7
|
import { setAdminPrefix, normalizeAdminPrefix, setAdminApiPrefix, normalizeAdminApiPrefix, } from './admin_prefix.js';
|
|
7
8
|
import { resolveRuntimeSettings } from './runtime_settings.js';
|
|
@@ -112,7 +113,10 @@ export const adminGuard = async (ctx, next) => {
|
|
|
112
113
|
}
|
|
113
114
|
const allowed = cfg.admin.roles;
|
|
114
115
|
const account = await cfg.accountStore.findById(accountId);
|
|
115
|
-
|
|
116
|
+
// Resolve roles through the host's role authority (`resolveTokenRoles`) when set — the same source
|
|
117
|
+
// the token claim is minted from — so an app-role admin reaches the console. Falls back to the
|
|
118
|
+
// account's stored `globalRoles` when no hook is configured.
|
|
119
|
+
const roles = account ? await resolveAccountRoles(cfg, account) : [];
|
|
116
120
|
const isAdmin = roles.some((r) => allowed.includes(r));
|
|
117
121
|
if (!isAdmin) {
|
|
118
122
|
// Evita vazar a existência do console admin: redireciona para o accountHome
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adonis-agora/authkit-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.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",
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"@adonis-agora/durable": "^0.2.0",
|
|
59
|
+
"@adonis-agora/media": "^0.1.0",
|
|
59
60
|
"@adonis-agora/telescope": "^0.1.0",
|
|
60
61
|
"@adonisjs/ally": "^6.3.0",
|
|
61
62
|
"@adonisjs/auth": "^10.1.0",
|
|
@@ -73,6 +74,9 @@
|
|
|
73
74
|
"@adonis-agora/durable": {
|
|
74
75
|
"optional": true
|
|
75
76
|
},
|
|
77
|
+
"@adonis-agora/media": {
|
|
78
|
+
"optional": true
|
|
79
|
+
},
|
|
76
80
|
"@adonis-agora/telescope": {
|
|
77
81
|
"optional": true
|
|
78
82
|
},
|