@adonis-agora/authkit-server 0.44.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/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/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 */
|
|
@@ -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).
|
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
|
},
|