Skip to content

Commit f7ffb81

Browse files
committed
Enhances custom avatar functionality with approval system for URL templates
(#5155)
1 parent 7b75365 commit f7ffb81

5 files changed

Lines changed: 98 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: 70 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,71 @@ 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+
// Unknown template — prompt the user (non-blocking) and fall through to fallback avatar
343+
void promptForAvatarTemplateApproval(template);
344+
return undefined;
345+
}
346+
347+
function getAvatarTemplateApproval(template: string): 'allow' | 'deny' | undefined {
348+
return Container.instance.storage.get('avatars:approvedRemoteTemplates')?.[template];
349+
}
350+
351+
async function setAvatarTemplateApproval(template: string, decision: 'allow' | 'deny'): Promise<void> {
352+
const approvals = { ...(Container.instance.storage.get('avatars:approvedRemoteTemplates') ?? {}) };
353+
approvals[template] = decision;
354+
await Container.instance.storage.store('avatars:approvedRemoteTemplates', approvals);
355+
}
356+
357+
async function promptForAvatarTemplateApproval(template: string): Promise<void> {
358+
if (promptedAvatarTemplates.has(template)) return;
359+
promptedAvatarTemplates.add(template);
360+
361+
const allow: MessageItem = { title: 'Allow' };
362+
const never: MessageItem = { title: 'Never' };
363+
const notNow: MessageItem = { title: 'Not Now', isCloseAffordance: true };
364+
365+
const result = await window.showInformationMessage(
366+
`GitLens wants to fetch contributor avatars from a URL configured by the current workspace. This URL will be requested for every commit author shown in this repository.\n\nTemplate: ${template}`,
367+
allow,
368+
never,
369+
notNow,
370+
);
371+
372+
if (result === allow) {
373+
await setAvatarTemplateApproval(template, 'allow');
374+
resetAvatarCache('failed');
375+
} else if (result === never) {
376+
await setAvatarTemplateApproval(template, 'deny');
377+
}
378+
}
379+
380+
export function resetApprovedAvatarTemplates(): Promise<void> {
381+
promptedAvatarTemplates.clear();
382+
resetAvatarCache('failed');
383+
return Container.instance.storage.delete('avatars:approvedRemoteTemplates');
384+
}
385+
319386
export function resetAvatarCache(reset: 'all' | 'failed' | 'fallback'): void {
320387
switch (reset) {
321388
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
'banners',
1617
'integrations',
1718
'previews',
@@ -48,6 +49,11 @@ export class ResetCommand extends GlCommandBase {
4849
detail: 'Clears the stored avatar cache',
4950
item: 'avatars',
5051
},
52+
{
53+
label: 'Approved Avatar URL Templates...',
54+
detail: 'Clears approvals granted to custom remote avatar URL templates',
55+
item: 'avatars:approvedTemplates',
56+
},
5157
{
5258
label: 'Banners...',
5359
detail: 'Resets dismissed banners/notices',
@@ -137,6 +143,11 @@ export class ResetCommand extends GlCommandBase {
137143
confirmationMessage = 'Are you sure you want to reset the avatar cache?';
138144
confirm.title = 'Reset Avatars';
139145
break;
146+
case 'avatars:approvedTemplates':
147+
confirmationMessage =
148+
'Are you sure you want to reset all approvals for custom remote avatar URL templates?';
149+
confirm.title = 'Reset Approved Avatar URL Templates';
150+
break;
140151
case 'banners':
141152
confirmationMessage = 'Are you sure you want to reset all dismissed banners/notices?';
142153
confirm.title = 'Reset Banners';
@@ -214,6 +225,10 @@ export class ResetCommand extends GlCommandBase {
214225
resetAvatarCache('all');
215226
break;
216227

228+
case 'avatars:approvedTemplates':
229+
await resetApprovedAvatarTemplates();
230+
break;
231+
217232
case 'banners':
218233
await this.container.onboarding.resetAll();
219234
await this.container.storage.delete('home:sections:collapsed');

src/constants.storage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type IntegrationAuthenticationKeys =
2626
export const enum SyncedStorageKeys {
2727
Version = 'gitlens:synced:version',
2828
PreReleaseVersion = 'gitlens:synced:preVersion',
29+
ApprovedAvatarRemoteTemplates = 'gitlens:avatars:approvedRemoteTemplates',
2930
}
3031

3132
export type DeprecatedGlobalStorage = {
@@ -77,6 +78,7 @@ export type DeprecatedGlobalStorage = {
7778

7879
interface GlobalStorageCore {
7980
avatars: [string, StoredAvatar][];
81+
'avatars:approvedRemoteTemplates': Record<string, 'allow' | 'deny'>;
8082
'confirm:ai:generateCommits': boolean;
8183
'confirm:ai:tos': boolean;
8284
repoVisibility: [string, StoredRepoVisibilityInfo][];

src/extension.ts

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

305305
function setKeysForSync(context: ExtensionContext, ...keys: (SyncedStorageKeys | string)[]) {
306-
context.globalState?.setKeysForSync([...keys, SyncedStorageKeys.Version, SyncedStorageKeys.PreReleaseVersion]);
306+
context.globalState?.setKeysForSync([
307+
...keys,
308+
SyncedStorageKeys.ApprovedAvatarRemoteTemplates,
309+
SyncedStorageKeys.Version,
310+
SyncedStorageKeys.PreReleaseVersion,
311+
]);
307312
}
308313

309314
function registerBuiltInActionRunners(container: Container): void {

0 commit comments

Comments
 (0)