Skip to content

Commit 64e24f4

Browse files
committed
fix(webapp): hide profile picture uploads when no avatar store is configured
1 parent 11afe1a commit 64e24f4

5 files changed

Lines changed: 71 additions & 4 deletions

File tree

apps/webapp/app/routes/account._index/route.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ import {
9292
} from "~/utils/themePreference";
9393
import { cachedFlag, resolveOrganizationFeatureFlags } from "~/v3/featureFlags.server";
9494
import { requireUser } from "~/services/session.server";
95+
import { isAvatarUploadsEnabled } from "~/services/userAvatar.server";
9596
import { emailSchema, MAX_EMAIL_LENGTH } from "~/utils/emailValidation";
9697
import { pageMeta } from "~/utils/pageTitle";
9798
import { cn } from "~/utils/cn";
@@ -263,7 +264,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
263264
});
264265
}
265266

266-
return json({ showThemeSwitcher, sidebarContext });
267+
return json({
268+
showThemeSwitcher,
269+
sidebarContext,
270+
avatarUploadsEnabled: isAvatarUploadsEnabled(),
271+
});
267272
}
268273

269274
export const action: ActionFunction = async ({ request }) => {
@@ -870,7 +875,8 @@ function CustomizeSidebarButton({
870875

871876
export default function Page() {
872877
const user = useUser();
873-
const { showThemeSwitcher, sidebarContext } = useLoaderData<typeof loader>();
878+
const { showThemeSwitcher, sidebarContext, avatarUploadsEnabled } =
879+
useLoaderData<typeof loader>();
874880
const themeFetcher = useFetcher<ProfileUpdateResult>();
875881
const contrastFetcher = useFetcher();
876882
const iconContrastFetcher = useFetcher();
@@ -990,7 +996,11 @@ export default function Page() {
990996
<Label>Profile picture</Label>
991997
</InputGroup>
992998
<div className="flex flex-none items-center">
993-
<ChangeProfilePhotoButton />
999+
{avatarUploadsEnabled ? (
1000+
<ChangeProfilePhotoButton />
1001+
) : (
1002+
<UserProfilePhoto className="size-8" strokeWidth={1.5} />
1003+
)}
9941004
</div>
9951005
</div>
9961006
</div>

apps/webapp/app/routes/resources.account.avatar.$userId.$filename.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { redirect } from "@remix-run/node";
22
import { z } from "zod";
33
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
44
import {
5+
isAvatarUploadsEnabled,
56
presignUserAvatarUrl,
67
readUserAvatarBytes,
78
resolveUserAvatarObjectPath,
@@ -24,7 +25,9 @@ const ParamsSchema = z.object({
2425
export const loader = dashboardLoader(
2526
{ params: ParamsSchema },
2627
async ({ params: { userId, filename }, request }) => {
27-
const objectPath = resolveUserAvatarObjectPath(userId, filename);
28+
const objectPath = isAvatarUploadsEnabled()
29+
? resolveUserAvatarObjectPath(userId, filename)
30+
: undefined;
2831

2932
if (!objectPath) {
3033
throw new Response("Not found", { status: 404 });

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
55
import {
66
deleteStaleUserAvatar,
77
isAvatarUploadRejection,
8+
isAvatarUploadsEnabled,
89
parseAvatarUpload,
910
uploadUserAvatar,
1011
} from "~/services/userAvatar.server";
@@ -20,6 +21,11 @@ export const action = dashboardAction({}, async ({ request, user }) => {
2021
return json({ error: "Method not allowed" }, { status: 405 });
2122
}
2223

24+
// An install with no avatar store hides this UI entirely; a stray request still answers.
25+
if (!isAvatarUploadsEnabled()) {
26+
return json({ error: "Profile pictures are not available on this instance." }, { status: 400 });
27+
}
28+
2329
// Read from the cookie: the builder's session user reports isImpersonating false.
2430
const { isImpersonating } = await getImpersonationState(request, user.id);
2531

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ const AVATAR_PRESIGN_EXPIRY_IN_SECONDS = 300;
1717
const AVATAR_FILENAME_REGEX = /^[0-9a-f]{32}\.(png|jpg|webp)$/;
1818
const USER_ID_REGEX = /^[A-Za-z0-9_-]+$/;
1919

20+
/**
21+
* Whether this deployment can store profile pictures at all. Self-hosted installs that
22+
* configure no avatar store keep the account page exactly as it was before the feature.
23+
*/
24+
export function isAvatarUploadsEnabled() {
25+
return Boolean(env.AVATARS_OBJECT_STORE_BASE_URL && env.AVATARS_OBJECT_STORE_BUCKET);
26+
}
27+
2028
/** Undefined when no avatar store is configured, so the policy stays unchanged. */
2129
export function avatarObjectStoreImageOrigin() {
2230
return imageOriginFromUrl(env.AVATARS_OBJECT_STORE_BASE_URL);

apps/webapp/test/userAvatar.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
isAvatarUploadRejection,
77
absoluteUserAvatarUrl,
88
avatarObjectStoreImageOrigin,
9+
isAvatarUploadsEnabled,
910
parseAvatarUpload,
1011
presignUserAvatarUrl,
1112
resolveStaleAvatarObjectPath,
@@ -373,3 +374,42 @@ describe("avatarContentTypeForFilename", () => {
373374
expect(avatarContentTypeForFilename(filename)).toBeUndefined();
374375
});
375376
});
377+
378+
describe("isAvatarUploadsEnabled", () => {
379+
const original = {
380+
baseUrl: env.AVATARS_OBJECT_STORE_BASE_URL,
381+
bucket: env.AVATARS_OBJECT_STORE_BUCKET,
382+
};
383+
384+
afterEach(() => {
385+
env.AVATARS_OBJECT_STORE_BASE_URL = original.baseUrl;
386+
env.AVATARS_OBJECT_STORE_BUCKET = original.bucket;
387+
});
388+
389+
it("is on when the store is fully configured", () => {
390+
env.AVATARS_OBJECT_STORE_BASE_URL = "http://localhost:9005";
391+
env.AVATARS_OBJECT_STORE_BUCKET = "avatars";
392+
393+
expect(isAvatarUploadsEnabled()).toBe(true);
394+
});
395+
396+
it.each([
397+
["nothing is configured", undefined, undefined],
398+
["only the base URL is set", "http://localhost:9005", undefined],
399+
["only the bucket is set", undefined, "avatars"],
400+
["the base URL is blank", "", "avatars"],
401+
["the bucket is blank", "http://localhost:9005", ""],
402+
])("is off when %s", (_case, baseUrl, bucket) => {
403+
env.AVATARS_OBJECT_STORE_BASE_URL = baseUrl;
404+
env.AVATARS_OBJECT_STORE_BUCKET = bucket;
405+
406+
expect(isAvatarUploadsEnabled()).toBe(false);
407+
});
408+
409+
it("never builds a client, so an unconfigured install can ask freely", () => {
410+
env.AVATARS_OBJECT_STORE_BASE_URL = undefined;
411+
env.AVATARS_OBJECT_STORE_BUCKET = undefined;
412+
413+
expect(() => isAvatarUploadsEnabled()).not.toThrow();
414+
});
415+
});

0 commit comments

Comments
 (0)