Skip to content

Commit 708df64

Browse files
committed
Enhances custom avatar functionality with approval system for URL templates
(#5155)
1 parent d031835 commit 708df64

5 files changed

Lines changed: 103 additions & 5 deletions

File tree

packages/git/src/remotes/custom.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export class CustomRemoteProvider extends RemoteProvider {
3535
return [];
3636
}
3737

38+
get avatarUrlTemplate(): string | undefined {
39+
return this.urls.avatar;
40+
}
41+
3842
getUrlForAvatar(email: string, size: number): string | undefined {
3943
if (this.urls.avatar == null) return undefined;
4044

src/avatars.ts

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { EventEmitter, Uri } from 'vscode';
1+
import type { MessageItem } from 'vscode';
2+
import { EventEmitter, Uri, window, workspace } from 'vscode';
23
import type { CommitAuthor } from '@gitlens/git/models/author.js';
34
import { CustomRemoteProvider } from '@gitlens/git/remotes/custom.js';
45
import { getGitHubNoReplyAddressParts } from '@gitlens/git/remotes/github.js';
@@ -247,18 +248,19 @@ async function getAvatarUriFromRemoteProvider(
247248
});
248249
}
249250

250-
if (!account) {
251+
if (!account?.avatarUrl) {
251252
const remoteWithProvider = await Container.instance.git
252253
.getRepositoryService(repoPathOrCommit.repoPath)
253254
.remotes.getBestRemoteWithProvider();
254255

255256
if (remoteWithProvider?.provider instanceof CustomRemoteProvider) {
256-
const avatarUrl = remoteWithProvider.provider.getUrlForAvatar(email, size);
257+
const avatarUrl = getApprovedCustomRemoteAvatarUrl(remoteWithProvider.provider, email, size);
257258
if (avatarUrl != null) {
258259
avatar.uri = Uri.parse(avatarUrl);
259260
avatar.timestamp = Date.now();
260261
avatar.retries = 0;
261262
avatarCache.set(`${md5(email.trim().toLowerCase())}:${size}`, { ...avatar });
263+
_onDidFetchAvatar.fire({ email: email });
262264
return avatar.uri;
263265
}
264266
}
@@ -316,6 +318,76 @@ export function getPresenceDataUri(status: ContactPresenceStatus): string {
316318
return dataUri;
317319
}
318320

321+
const promptedAvatarTemplates = new Set<string>();
322+
323+
function getApprovedCustomRemoteAvatarUrl(
324+
provider: CustomRemoteProvider,
325+
email: string,
326+
size: number,
327+
): string | undefined {
328+
// Avatar templates from `gitlens.remotes` may originate from workspace settings
329+
// — only honor them in a trusted workspace
330+
if (!workspace.isTrusted) return undefined;
331+
332+
const template = provider.avatarUrlTemplate;
333+
if (template == null) return undefined;
334+
335+
const approval = getAvatarTemplateApproval(template);
336+
if (approval === 'allow') {
337+
return provider.getUrlForAvatar(email, size);
338+
}
339+
if (approval === 'deny') {
340+
return undefined;
341+
}
342+
343+
// Unknown template — prompt the user (non-blocking) and fall through to fallback avatar
344+
void promptForAvatarTemplateApproval(template);
345+
return undefined;
346+
}
347+
348+
function getAvatarTemplateApproval(template: string): 'allow' | 'deny' | undefined {
349+
const approvals = Container.instance.storage.get('avatars:approvedRemoteTemplates');
350+
if (approvals == null) return undefined;
351+
return Object.hasOwn(approvals, template) ? approvals[template] : undefined;
352+
}
353+
354+
async function setAvatarTemplateApproval(template: string, decision: 'allow' | 'deny'): Promise<void> {
355+
const approvals: Record<string, 'allow' | 'deny'> = Object.create(null);
356+
Object.assign(approvals, Container.instance.storage.get('avatars:approvedRemoteTemplates'));
357+
approvals[template] = decision;
358+
await Container.instance.storage.store('avatars:approvedRemoteTemplates', approvals);
359+
}
360+
361+
async function promptForAvatarTemplateApproval(template: string): Promise<void> {
362+
if (promptedAvatarTemplates.has(template)) return;
363+
364+
promptedAvatarTemplates.add(template);
365+
366+
const allow: MessageItem = { title: 'Allow' };
367+
const deny: MessageItem = { title: 'Deny' };
368+
const notNow: MessageItem = { title: 'Not Now', isCloseAffordance: true };
369+
370+
const result = await window.showInformationMessage(
371+
`The \`gitlens.remotes\` setting in this workspace includes an avatar URL template that will be requested for every commit author.\n\nTemplate: ${template}\n\nDo you trust this workspace to make these requests?`,
372+
allow,
373+
deny,
374+
notNow,
375+
);
376+
377+
if (result === allow) {
378+
await setAvatarTemplateApproval(template, 'allow');
379+
resetAvatarCache('failed');
380+
} else if (result === deny) {
381+
await setAvatarTemplateApproval(template, 'deny');
382+
}
383+
}
384+
385+
export function resetApprovedAvatarTemplates(): Promise<void> {
386+
promptedAvatarTemplates.clear();
387+
resetAvatarCache('failed');
388+
return Container.instance.storage.delete('avatars:approvedRemoteTemplates');
389+
}
390+
319391
export function resetAvatarCache(reset: 'all' | 'failed' | 'fallback'): void {
320392
switch (reset) {
321393
case 'all':

src/commands/resets.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { MessageItem } from 'vscode';
22
import { ConfigurationTarget, window } from 'vscode';
3-
import { resetAvatarCache } from '../avatars.js';
3+
import { resetApprovedAvatarTemplates, resetAvatarCache } from '../avatars.js';
44
import type { Container } from '../container.js';
55
import type { QuickPickItemOfT } from '../quickpicks/items/common.js';
66
import { createQuickPickSeparator } from '../quickpicks/items/common.js';
@@ -12,6 +12,7 @@ const resetTypes = [
1212
'ai',
1313
'ai:confirmations',
1414
'avatars',
15+
'avatars:approvedTemplates',
1516
'integrations',
1617
'onboarding',
1718
'previews',
@@ -47,6 +48,11 @@ export class ResetCommand extends GlCommandBase {
4748
detail: 'Clears the stored avatar cache',
4849
item: 'avatars',
4950
},
51+
{
52+
label: 'Approved Avatar URL Templates...',
53+
detail: 'Clears approvals granted to custom remote avatar URL templates',
54+
item: 'avatars:approvedTemplates',
55+
},
5056
{
5157
label: 'Integrations (Authentication)...',
5258
detail: 'Clears any locally stored authentication for integrations',
@@ -131,6 +137,11 @@ export class ResetCommand extends GlCommandBase {
131137
confirmationMessage = 'Are you sure you want to reset the avatar cache?';
132138
confirm.title = 'Reset Avatars';
133139
break;
140+
case 'avatars:approvedTemplates':
141+
confirmationMessage =
142+
'Are you sure you want to reset all approvals for custom remote avatar URL templates?';
143+
confirm.title = 'Reset Approved Avatar URL Templates';
144+
break;
134145
case 'integrations':
135146
confirmationMessage = 'Are you sure you want to reset all of the stored integrations?';
136147
confirm.title = 'Reset Integrations';
@@ -205,6 +216,10 @@ export class ResetCommand extends GlCommandBase {
205216
resetAvatarCache('all');
206217
break;
207218

219+
case 'avatars:approvedTemplates':
220+
await resetApprovedAvatarTemplates();
221+
break;
222+
208223
case 'integrations':
209224
await this.container.integrations.reset();
210225
break;

src/constants.storage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type IntegrationAuthenticationKeys =
3434
export const enum SyncedStorageKeys {
3535
Version = 'gitlens:synced:version',
3636
PreReleaseVersion = 'gitlens:synced:preVersion',
37+
ApprovedAvatarRemoteTemplates = 'gitlens:avatars:approvedRemoteTemplates',
3738
}
3839

3940
export type DeprecatedGlobalStorage = {
@@ -87,6 +88,7 @@ interface GlobalStorageCore {
8788
avatars: [string, StoredAvatar][];
8889
'ai:scope:compose:model': AIProviderAndModel;
8990
'ai:scope:review:model': AIProviderAndModel;
91+
'avatars:approvedRemoteTemplates': Record<string, 'allow' | 'deny'>;
9092
'confirm:ai:generateCommits': boolean;
9193
'confirm:ai:tos': boolean;
9294
repoVisibility: [string, StoredRepoVisibilityInfo][];

src/extension.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,12 @@ export function deactivate(): void {
311311
// }
312312

313313
function setKeysForSync(context: ExtensionContext, ...keys: (SyncedStorageKeys | string)[]) {
314-
context.globalState?.setKeysForSync([...keys, SyncedStorageKeys.Version, SyncedStorageKeys.PreReleaseVersion]);
314+
context.globalState?.setKeysForSync([
315+
...keys,
316+
SyncedStorageKeys.ApprovedAvatarRemoteTemplates,
317+
SyncedStorageKeys.Version,
318+
SyncedStorageKeys.PreReleaseVersion,
319+
]);
315320
}
316321

317322
function registerBuiltInActionRunners(container: Container): void {

0 commit comments

Comments
 (0)