Skip to content

Commit 1dd4c58

Browse files
authored
Add storage-backed profile image uploads (#2293)
* Add storage-backed profile images * Address profile image PR feedback * Refine avatar upload controls * Fix profile image upload size limits * Store org profile images under org storage * Remove legacy profile image storage paths * Store profile image filenames * Address profile image PR feedback * Avoid swallowing cancellation during image cleanup
1 parent 4b884ef commit 1dd4c58

37 files changed

Lines changed: 1741 additions & 133 deletions

src/Exceptionless.Core/Jobs/CleanupDataJob.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
using Exceptionless.Core.Repositories;
55
using Exceptionless.Core.Repositories.Queries;
66
using Exceptionless.Core.Services;
7+
using Exceptionless.Core.Utility;
78
using Exceptionless.DateTimeExtensions;
89
using Foundatio.Caching;
910
using Foundatio.Jobs;
1011
using Foundatio.Lock;
1112
using Foundatio.Repositories;
1213
using Foundatio.Repositories.Models;
1314
using Foundatio.Resilience;
15+
using Foundatio.Storage;
1416
using Microsoft.Extensions.Diagnostics.HealthChecks;
1517
using Microsoft.Extensions.Logging;
1618

@@ -31,6 +33,7 @@ public class CleanupDataJob : JobWithLockBase, IHealthCheck
3133
private readonly AppOptions _appOptions;
3234
private readonly ILockProvider _lockProvider;
3335
private readonly ICacheClient _cacheClient;
36+
private readonly IFileStorage _fileStorage;
3437
private DateTime? _lastRun;
3538

3639
public CleanupDataJob(
@@ -43,6 +46,7 @@ public CleanupDataJob(
4346
IWebHookRepository webHookRepository,
4447
ILockProvider lockProvider,
4548
ICacheClient cacheClient,
49+
IFileStorage fileStorage,
4650
BillingManager billingManager,
4751
UsageService usageService,
4852
AppOptions appOptions,
@@ -63,6 +67,7 @@ ILoggerFactory loggerFactory
6367
_appOptions = appOptions;
6468
_lockProvider = lockProvider;
6569
_cacheClient = cacheClient;
70+
_fileStorage = fileStorage;
6671
}
6772

6873
protected override Task<ILock?> GetLockAsync(CancellationToken cancellationToken = default)
@@ -196,10 +201,26 @@ private async Task RemoveOrganizationAsync(Organization organization, JobContext
196201
await RenewLockAsync(context);
197202
long removedProjects = await _projectRepository.RemoveAllByOrganizationIdAsync(organization.Id);
198203

204+
await RenewLockAsync(context);
205+
await RemoveOrganizationFilesAsync(organization, context);
206+
199207
await _organizationRepository.RemoveAsync(organization);
200208
_logger.RemoveOrganizationComplete(organization.Name, organization.Id, removedProjects, removedStacks, removedEvents);
201209
}
202210

211+
private Task RemoveOrganizationFilesAsync(Organization organization, JobContext context)
212+
=> RemoveFilesAsync(OrganizationStoragePaths.GetProfileImagesPath(organization.Id), context.CancellationToken);
213+
214+
private async Task RemoveFilesAsync(string path, CancellationToken cancellationToken)
215+
{
216+
string searchPattern = $"{path}/*";
217+
var files = await _fileStorage.GetFileListAsync(searchPattern, cancellationToken: cancellationToken);
218+
if (!files.Any())
219+
return;
220+
221+
await _fileStorage.DeleteFilesAsync(searchPattern, cancellationToken);
222+
}
223+
203224
private async Task RemoveProjectsAsync(Project project, JobContext context)
204225
{
205226
_logger.RemoveProjectStart(project.Name, project.Id);

src/Exceptionless.Core/Models/Organization.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ public Organization()
3131
[Required]
3232
public string Name { get; set; } = null!;
3333

34+
[StringLength(2000)]
35+
public string? IconFileName { get; set; }
36+
3437
/// <summary>
3538
/// Stripe customer id that will be charged.
3639
/// </summary>

src/Exceptionless.Core/Models/User.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ public record User : IIdentity, IHaveDates, IValidatableObject
3333
[Required]
3434
[EmailAddress]
3535
public string EmailAddress { get; set; } = null!;
36+
37+
[StringLength(2000)]
38+
public string? AvatarFileName { get; set; }
39+
3640
public bool EmailNotificationsEnabled { get; set; } = true;
3741
public bool IsEmailAddressVerified { get; set; }
3842
public string? VerifyEmailAddressToken { get; set; }
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace Exceptionless.Core.Utility;
2+
3+
public static class OrganizationStoragePaths
4+
{
5+
public static string GetRootPath(string organizationId)
6+
=> $"organizations/{organizationId}";
7+
8+
public static string GetProfileImagesPath(string organizationId)
9+
=> $"{GetRootPath(organizationId)}/profile-images";
10+
11+
public static string GetProfileImagePath(string organizationId, string fileName)
12+
=> $"{GetProfileImagesPath(organizationId)}/{fileName}";
13+
}

src/Exceptionless.Web/ClientApp/api-templates/schemas.ejs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ const typeToZod = (rawType, isRequired, nullable, field) => {
136136
}
137137
138138
switch (type) {
139+
case 'JsonElement':
140+
case 'JsonDocument':
141+
usedZodFunctions.add('unknown');
142+
return 'unknown()';
139143
case 'string':
140144
usedZodFunctions.add('string');
141145
return 'string()';
@@ -172,6 +176,12 @@ const typeToZod = (rawType, isRequired, nullable, field) => {
172176
if (contract.typeIdentifier === 'enum') {
173177
return type + 'Schema';
174178
}
179+
180+
if ((contract.$content || []).length === 0) {
181+
usedZodFunctions.add('unknown');
182+
return 'unknown()';
183+
}
184+
175185
usedZodFunctions.add('lazy');
176186
return 'lazy(() => ' + type + 'Schema)';
177187
}

src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { BillingPlan, ChangePlanRequest, ChangePlanResult } from '$lib/gene
33
import type { QueryClient } from '@tanstack/svelte-query';
44

55
import { accessToken } from '$features/auth/index.svelte';
6+
import { fetchApiJson } from '$features/shared/api/api.svelte';
67
import { queryKeys as userQueryKeys } from '$features/users/api.svelte';
78
import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
89
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
@@ -26,6 +27,7 @@ export const queryKeys = {
2627
adminSearch: (params: GetAdminSearchOrganizationsParams) => [...queryKeys.list(params.mode), 'admin', { ...params }] as const,
2728
changePlan: (id: string | undefined) => [...queryKeys.type, id, 'change-plan'] as const,
2829
deleteOrganization: (ids: string[] | undefined) => [...queryKeys.ids(ids), 'delete'] as const,
30+
icon: (id: string | undefined) => [...queryKeys.id(id, undefined), 'icon'] as const,
2931
id: (id: string | undefined, mode: 'stats' | undefined) => (mode ? ([...queryKeys.type, id, { mode }] as const) : ([...queryKeys.type, id] as const)),
3032
ids: (ids: string[] | undefined) => [...queryKeys.type, ...(ids ?? [])] as const,
3133
invoice: (id: string | undefined) => [...queryKeys.type, 'invoice', id] as const,
@@ -128,6 +130,12 @@ export interface GetPlansRequest {
128130
};
129131
}
130132

133+
export interface OrganizationIconRequest {
134+
route: {
135+
id: string | undefined;
136+
};
137+
}
138+
131139
export interface PatchOrganizationRequest {
132140
route: {
133141
id: string;
@@ -217,6 +225,21 @@ export function deleteOrganization(request: DeleteOrganizationRequest) {
217225
}));
218226
}
219227

228+
export function deleteOrganizationIcon(request: OrganizationIconRequest) {
229+
const queryClient = useQueryClient();
230+
231+
return createMutation<ViewOrganization, ProblemDetails, void>(() => ({
232+
enabled: () => !!accessToken.current && !!request.route.id,
233+
mutationFn: async () => {
234+
return await fetchApiJson<ViewOrganization>(`organizations/${request.route.id}/icon`, {
235+
method: 'DELETE'
236+
});
237+
},
238+
mutationKey: queryKeys.icon(request.route.id),
239+
onSuccess: (organization: ViewOrganization) => updateOrganizationCache(queryClient, request.route.id, organization)
240+
}));
241+
}
242+
220243
export function deleteOrganizationUser(request: DeleteOrganizationUserRequest) {
221244
const queryClient = useQueryClient();
222245
return createMutation<void, ProblemDetails, void>(() => ({
@@ -400,10 +423,7 @@ export function patchOrganization(request: PatchOrganizationRequest) {
400423
onError: () => {
401424
queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id, undefined) });
402425
},
403-
onSuccess: (organization: ViewOrganization) => {
404-
queryClient.setQueryData(queryKeys.id(request.route.id, 'stats'), organization);
405-
queryClient.setQueryData(queryKeys.id(request.route.id, undefined), organization);
406-
}
426+
onSuccess: (organization: ViewOrganization) => updateOrganizationCache(queryClient, request.route.id, organization)
407427
}));
408428
}
409429

@@ -539,3 +559,38 @@ export function setOrganizationFeature(request: SetOrganizationFeatureRequest) {
539559
}
540560
}));
541561
}
562+
563+
export function uploadOrganizationIcon(request: OrganizationIconRequest) {
564+
const queryClient = useQueryClient();
565+
566+
return createMutation<ViewOrganization, ProblemDetails, File>(() => ({
567+
enabled: () => !!accessToken.current && !!request.route.id,
568+
mutationFn: async (file: File) => {
569+
const data = new FormData();
570+
data.append('file', file);
571+
return await fetchApiJson<ViewOrganization>(`organizations/${request.route.id}/icon`, {
572+
body: data,
573+
method: 'POST'
574+
});
575+
},
576+
mutationKey: queryKeys.icon(request.route.id),
577+
onSuccess: (organization: ViewOrganization) => updateOrganizationCache(queryClient, request.route.id, organization)
578+
}));
579+
}
580+
581+
function updateOrganizationCache(queryClient: QueryClient, id: string | undefined, organization: ViewOrganization) {
582+
queryClient.setQueryData(queryKeys.id(id, 'stats'), organization);
583+
queryClient.setQueryData(queryKeys.id(id, undefined), organization);
584+
queryClient.setQueriesData<FetchClientResponse<ViewOrganization[]> | undefined>({ queryKey: queryKeys.type }, (response) => {
585+
if (!Array.isArray(response?.data) || !response.data.some((existingOrganization) => existingOrganization.id === organization.id)) {
586+
return response;
587+
}
588+
589+
return {
590+
...response,
591+
data: response.data.map((existingOrganization) => {
592+
return existingOrganization.id === organization.id ? organization : existingOrganization;
593+
})
594+
};
595+
});
596+
}

src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/api.svelte.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { FetchClient, FetchClientProvider, getCurrentProvider } from '@exceptionless/fetchclient';
1+
import { accessToken } from '$features/auth/index.svelte';
2+
import { FetchClient, FetchClientProvider, getCurrentProvider, ProblemDetails } from '@exceptionless/fetchclient';
23
import { SvelteDate } from 'svelte/reactivity';
34

45
export const DEFAULT_LIMIT = 20;
@@ -24,6 +25,39 @@ export class FetchClientStatus {
2425
}
2526
}
2627

28+
export async function fetchApiJson<T>(path: string, init: RequestInit): Promise<T> {
29+
const headers = new Headers(init.headers);
30+
if (accessToken.current) {
31+
headers.set('Authorization', `Bearer ${accessToken.current}`);
32+
}
33+
34+
const response = await fetch(`/api/v2/${path.replace(/^\/+/, '')}`, {
35+
...init,
36+
headers
37+
});
38+
39+
if (!response.ok) {
40+
throw await toProblemDetails(response);
41+
}
42+
43+
return (await response.json()) as T;
44+
}
45+
2746
export function useFetchClientStatus(target?: FetchClient | FetchClientProvider) {
2847
return new FetchClientStatus(target);
2948
}
49+
50+
async function toProblemDetails(response: Response): Promise<ProblemDetails> {
51+
const contentType = response.headers.get('Content-Type') ?? '';
52+
if (contentType.startsWith('application/problem+json') || contentType.startsWith('application/json')) {
53+
try {
54+
const problem = Object.assign(new ProblemDetails(), (await response.json()) as Partial<ProblemDetails>);
55+
problem.status ??= response.status;
56+
return problem;
57+
} catch {
58+
// Fall through to a generic error for non-problem JSON payloads.
59+
}
60+
}
61+
62+
return new ProblemDetails().setErrorMessage(`Unexpected status code: ${response.status}`);
63+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const PROFILE_IMAGE_MAX_FILE_SIZE = 5 * 1024 * 1024;
2+
export const PROFILE_IMAGE_MAX_FILE_SIZE_LABEL = '5 MB';
3+
4+
export function getProfileImageFileError(file: File): null | string {
5+
if (file.size > PROFILE_IMAGE_MAX_FILE_SIZE) {
6+
return `Image files must be ${PROFILE_IMAGE_MAX_FILE_SIZE_LABEL} or smaller.`;
7+
}
8+
9+
return null;
10+
}

src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { WorkInProgressResult } from '$shared/models';
33

44
import { setUserIdentity } from '$features/auth/exceptionless-session';
55
import { accessToken } from '$features/auth/index.svelte';
6+
import { fetchApiJson } from '$features/shared/api/api.svelte';
67
import { type FetchClientResponse, ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
78
import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query';
89

@@ -23,6 +24,7 @@ export async function invalidateUserQueries(queryClient: QueryClient, message: W
2324
}
2425

2526
export const queryKeys = {
27+
avatar: (id: string | undefined) => [...queryKeys.id(id), 'avatar'] as const,
2628
deleteCurrentUser: () => [...queryKeys.me(), 'delete'] as const,
2729
id: (id: string | undefined) => [...queryKeys.type, id] as const,
2830
idEmailAddress: (id?: string) => [...queryKeys.id(id), 'email-address'] as const,
@@ -64,6 +66,12 @@ export interface ResendVerificationEmailRequest {
6466
};
6567
}
6668

69+
export interface UserAvatarRequest {
70+
route: {
71+
id: string | undefined;
72+
};
73+
}
74+
6775
export function deleteCurrentUser() {
6876
return createMutation<WorkInProgressResult, ProblemDetails, void>(() => ({
6977
enabled: () => !!accessToken.current,
@@ -76,6 +84,27 @@ export function deleteCurrentUser() {
7684
}));
7785
}
7886

87+
export function deleteUserAvatar(request: UserAvatarRequest) {
88+
const queryClient = useQueryClient();
89+
return createMutation<ViewCurrentUser, ProblemDetails, void>(() => ({
90+
enabled: () => !!accessToken.current && !!request.route.id,
91+
mutationFn: async () => {
92+
return await fetchApiJson<ViewCurrentUser>(`users/${request.route.id}/avatar`, {
93+
method: 'DELETE'
94+
});
95+
},
96+
mutationKey: queryKeys.avatar(request.route.id),
97+
onSuccess: (data) => {
98+
queryClient.setQueryData(queryKeys.id(request.route.id), data);
99+
100+
const currentUser = queryClient.getQueryData<ViewCurrentUser>(queryKeys.me());
101+
if (currentUser?.id === request.route.id) {
102+
queryClient.setQueryData(queryKeys.me(), data);
103+
}
104+
}
105+
}));
106+
}
107+
79108
export function getMeQuery() {
80109
const queryClient = useQueryClient();
81110

@@ -185,3 +214,27 @@ export function resendVerificationEmail(request: ResendVerificationEmailRequest)
185214
mutationKey: [...queryKeys.id(request.route.id), 'resend-verification-email']
186215
}));
187216
}
217+
218+
export function uploadUserAvatar(request: UserAvatarRequest) {
219+
const queryClient = useQueryClient();
220+
return createMutation<ViewCurrentUser, ProblemDetails, File>(() => ({
221+
enabled: () => !!accessToken.current && !!request.route.id,
222+
mutationFn: async (file: File) => {
223+
const data = new FormData();
224+
data.append('file', file);
225+
return await fetchApiJson<ViewCurrentUser>(`users/${request.route.id}/avatar`, {
226+
body: data,
227+
method: 'POST'
228+
});
229+
},
230+
mutationKey: queryKeys.avatar(request.route.id),
231+
onSuccess: (data) => {
232+
queryClient.setQueryData(queryKeys.id(request.route.id), data);
233+
234+
const currentUser = queryClient.getQueryData<ViewCurrentUser>(queryKeys.me());
235+
if (currentUser?.id === request.route.id) {
236+
queryClient.setQueryData(queryKeys.me(), data);
237+
}
238+
}
239+
}));
240+
}

src/Exceptionless.Web/ClientApp/src/lib/features/users/gravatar.svelte.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,25 @@ export interface Gravatar {
99
export function getGravatarFromCurrentUser(query?: ReturnType<typeof getMeQuery>): Gravatar {
1010
const meQuery = query ?? getMeQuery();
1111
const fullName = $derived<string | undefined>(meQuery.data?.full_name);
12+
const avatarUrl = $derived<null | string | undefined>(meQuery.data?.avatar_url);
1213
const emailAddress = $derived<string | undefined>(meQuery.data?.email_address);
1314

1415
return {
1516
get initials() {
1617
return getInitials(fullName);
1718
},
1819
get src() {
20+
if (avatarUrl?.trim()) {
21+
return Promise.resolve(avatarUrl);
22+
}
23+
1924
return emailAddress ? getGravatarSrc(emailAddress) : Promise.resolve(null);
2025
}
2126
};
2227
}
2328

2429
export async function getGravatarSrc(emailAddress: string) {
25-
const hash = await getGravatarEmailHash(emailAddress);
30+
const hash = await getGravatarEmailHash(emailAddress.trim().toLowerCase());
2631
return `//www.gravatar.com/avatar/${hash}?default=mm&size=100&d=mp&r=g`;
2732
}
2833

0 commit comments

Comments
 (0)