Skip to content

Commit 9245092

Browse files
committed
feat(webapp): delete the previous profile photo after a new one is stored
1 parent 8068b39 commit 9245092

4 files changed

Lines changed: 131 additions & 3 deletions

File tree

apps/webapp/app/routes/resources.account.avatar.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { json, type ActionFunctionArgs } from "@remix-run/node";
22
import { updateUserAvatarUrl } from "~/models/user.server";
33
import { requireUser } from "~/services/session.server";
44
import {
5+
deleteStaleUserAvatar,
56
isAvatarUploadRejection,
67
parseAvatarUpload,
78
uploadUserAvatar,
@@ -20,9 +21,13 @@ export async function action({ request }: ActionFunctionArgs) {
2021
return json({ error: upload.error }, { status: upload.status });
2122
}
2223

23-
const { avatarUrl } = await uploadUserAvatar({ userId: user.id, ...upload });
24+
const previousAvatarUrl = user.avatarUrl;
25+
26+
const { avatarUrl, filename } = await uploadUserAvatar({ userId: user.id, ...upload });
2427

2528
await updateUserAvatarUrl({ id: user.id, avatarUrl });
2629

30+
await deleteStaleUserAvatar({ previousAvatarUrl, userId: user.id, filename });
31+
2732
return json({ avatarUrl });
2833
}

apps/webapp/app/services/userAvatar.server.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createHash } from "node:crypto";
2+
import { logger } from "~/services/logger.server";
23
import {
34
AVATAR_EXTENSIONS,
45
type AvatarContentType,
@@ -98,7 +99,60 @@ export async function uploadUserAvatar({
9899
const { client, objectKey } = requireAvatarObjectStore();
99100
await client.putObject(objectKey(path), data, contentType);
100101

101-
return { avatarUrl: buildUserAvatarUrl(userId, filename) };
102+
return { filename, avatarUrl: buildUserAvatarUrl(userId, filename) };
103+
}
104+
105+
const AVATAR_URL_REGEX = /^\/resources\/account\/avatar\/([^/]+)\/([^/]+)$/;
106+
107+
/**
108+
* Undefined unless the stored URL is this user's own avatar route and names a different object:
109+
* an OAuth avatar elsewhere is not ours to delete, and the same content hash is the same file.
110+
*/
111+
export function resolveStaleAvatarObjectPath({
112+
previousAvatarUrl,
113+
userId,
114+
filename,
115+
}: {
116+
previousAvatarUrl: string | null;
117+
userId: string;
118+
filename: string;
119+
}): string | undefined {
120+
const match = previousAvatarUrl?.match(AVATAR_URL_REGEX);
121+
122+
if (!match) {
123+
return undefined;
124+
}
125+
126+
const [, previousUserId, previousFilename] = match;
127+
128+
if (previousUserId !== userId || previousFilename === filename) {
129+
return undefined;
130+
}
131+
132+
return resolveUserAvatarObjectPath(previousUserId, previousFilename);
133+
}
134+
135+
export async function deleteStaleUserAvatar(options: {
136+
previousAvatarUrl: string | null;
137+
userId: string;
138+
filename: string;
139+
}) {
140+
const path = resolveStaleAvatarObjectPath(options);
141+
142+
if (!path) {
143+
return;
144+
}
145+
146+
try {
147+
const { client, objectKey } = requireAvatarObjectStore();
148+
await client.deleteObject(objectKey(path));
149+
} catch (error) {
150+
logger.warn("Failed to delete the previous avatar", {
151+
userId: options.userId,
152+
path,
153+
error: error instanceof Error ? error.message : String(error),
154+
});
155+
}
102156
}
103157

104158
export function presignUserAvatarUrl(objectPath: string) {

apps/webapp/app/v3/objectStoreClient.server.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { AwsClient } from "aws4fetch";
2-
import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
2+
import {
3+
DeleteObjectCommand,
4+
GetObjectCommand,
5+
PutObjectCommand,
6+
S3Client,
7+
} from "@aws-sdk/client-s3";
38
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
49

510
/**
@@ -19,6 +24,7 @@ interface IObjectStoreClient {
1924
contentType: string
2025
): Promise<string>;
2126
getObject(key: string): Promise<string>;
27+
deleteObject(key: string): Promise<void>;
2228
presign(key: string, method: "PUT" | "GET", expiresIn: number): Promise<string>;
2329
}
2430

@@ -76,6 +82,13 @@ class Aws4FetchClient implements IObjectStoreClient {
7682
return response.text();
7783
}
7884

85+
async deleteObject(key: string): Promise<void> {
86+
const response = await this.awsClient.fetch(this.buildUrl(key), { method: "DELETE" });
87+
if (!response.ok) {
88+
throw new Error(`Failed to delete from object store: ${response.statusText}`);
89+
}
90+
}
91+
7992
async presign(key: string, method: "PUT" | "GET", expiresIn: number): Promise<string> {
8093
const url = new URL(this.config.baseUrl);
8194
url.pathname = normalizeObjectStoreLogicalKeyPathname(key);
@@ -151,6 +164,12 @@ class AwsSdkClient implements IObjectStoreClient {
151164
return response.Body.transformToString();
152165
}
153166

167+
async deleteObject(key: string): Promise<void> {
168+
await this.s3Client.send(
169+
new DeleteObjectCommand({ Bucket: this.config.bucket, Key: this.toS3ObjectKey(key) })
170+
);
171+
}
172+
154173
async presign(key: string, method: "PUT" | "GET", expiresIn: number): Promise<string> {
155174
const s3Key = this.toS3ObjectKey(key);
156175
const command =
@@ -221,6 +240,10 @@ export class ObjectStoreClient implements IObjectStoreClient {
221240
return this.impl.getObject(key);
222241
}
223242

243+
deleteObject(key: string): Promise<void> {
244+
return this.impl.deleteObject(key);
245+
}
246+
224247
presign(key: string, method: "PUT" | "GET", expiresIn: number): Promise<string> {
225248
return this.impl.presign(key, method, expiresIn);
226249
}

apps/webapp/test/userAvatar.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
buildUserAvatarUrl,
55
isAvatarUploadRejection,
66
parseAvatarUpload,
7+
resolveStaleAvatarObjectPath,
78
resolveUserAvatarObjectPath,
89
} from "~/services/userAvatar.server";
910
import { MAX_AVATAR_SIZE_IN_BYTES } from "~/utils/avatarLimits";
@@ -112,3 +113,48 @@ describe("parseAvatarUpload", () => {
112113
expect(isAvatarUploadRejection(await parseAvatarUpload(form))).toBe(false);
113114
});
114115
});
116+
117+
describe("resolveStaleAvatarObjectPath", () => {
118+
const previous = filenameFor([1, 2, 3]);
119+
const next = filenameFor([4, 5, 6]);
120+
121+
it("derives the old object from the stored URL", () => {
122+
expect(
123+
resolveStaleAvatarObjectPath({
124+
previousAvatarUrl: buildUserAvatarUrl(USER_ID, previous),
125+
userId: USER_ID,
126+
filename: next,
127+
})
128+
).toBe(`avatars/${USER_ID}/${previous}`);
129+
});
130+
131+
it("keeps the object when the content hash is unchanged", () => {
132+
expect(
133+
resolveStaleAvatarObjectPath({
134+
previousAvatarUrl: buildUserAvatarUrl(USER_ID, previous),
135+
userId: USER_ID,
136+
filename: previous,
137+
})
138+
).toBeUndefined();
139+
});
140+
141+
it.each([
142+
["no previous avatar", null],
143+
["an OAuth avatar hosted elsewhere", "https://avatars.githubusercontent.com/u/1?v=4"],
144+
[
145+
"an absolute URL onto our own path",
146+
`https://evil.test/resources/account/avatar/${USER_ID}/${previous}`,
147+
],
148+
["another user's avatar", `/resources/account/avatar/usr_other/${previous}`],
149+
[
150+
"a filename that is not content-addressed",
151+
`/resources/account/avatar/${USER_ID}/../../secret.png`,
152+
],
153+
["a deeper path", `/resources/account/avatar/${USER_ID}/${previous}/extra`],
154+
["an unrelated app path", "/resources/account/photo"],
155+
])("leaves %s alone", (_case, previousAvatarUrl) => {
156+
expect(
157+
resolveStaleAvatarObjectPath({ previousAvatarUrl, userId: USER_ID, filename: next })
158+
).toBeUndefined();
159+
});
160+
});

0 commit comments

Comments
 (0)