Skip to content

Commit df5dd0b

Browse files
Copilottommoor
andauthored
Fix custom team logo not appearing in link previews for public shares (outline#11872)
* Initial plan * fix: resolve team avatar to signed URL for public share link previews Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> Agent-Logs-Url: https://github.com/outline/outline/sessions/3632734e-1bb5-4705-bdcd-a2ccbb211af8 * refactor: move avatar URL resolution to Team.publicAvatarUrl() Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> Agent-Logs-Url: https://github.com/outline/outline/sessions/a2191be3-0533-459a-8366-602bb798a60e * test: add Team.publicAvatarUrl model tests; update JSDoc Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> Agent-Logs-Url: https://github.com/outline/outline/sessions/7609501c-a4d1-44ea-a7bf-fa6fd8e7c999 * test: fix Team.publicAvatarUrl tests to use actual attachment URLs Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> Agent-Logs-Url: https://github.com/outline/outline/sessions/0a768f8b-0dd8-4e7a-a50d-873af58aab28 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
1 parent 3cc85f1 commit df5dd0b

File tree

3 files changed

+87
-2
lines changed

3 files changed

+87
-2
lines changed

server/models/Team.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { buildTeam, buildCollection } from "@server/test/factories";
1+
import { randomUUID } from "node:crypto";
2+
import { buildTeam, buildCollection, buildAttachment } from "@server/test/factories";
23

34
describe("Team", () => {
45
describe("collectionIds", () => {
@@ -40,4 +41,52 @@ describe("Team", () => {
4041
expect(team.previousSubdomains?.[1]).toEqual(subdomain);
4142
});
4243
});
44+
45+
describe("publicAvatarUrl", () => {
46+
it("should return null when no avatarUrl is set", async () => {
47+
const team = await buildTeam({ avatarUrl: null });
48+
const result = await team.publicAvatarUrl();
49+
expect(result).toBeNull();
50+
});
51+
52+
it("should return external URL unchanged", async () => {
53+
const url = "https://example.com/logo.png";
54+
const team = await buildTeam({ avatarUrl: url });
55+
const result = await team.publicAvatarUrl();
56+
expect(result).toEqual(url);
57+
});
58+
59+
it("should return signed URL for private-bucket attachment redirect", async () => {
60+
const team = await buildTeam();
61+
const attachment = await buildAttachment({
62+
teamId: team.id,
63+
acl: "private",
64+
});
65+
66+
await team.update({
67+
avatarUrl: `/api/attachments.redirect?id=${attachment.id}`,
68+
});
69+
70+
const result = await team.publicAvatarUrl();
71+
expect(result).toEqual(await attachment.signedUrl);
72+
});
73+
74+
it("should return canonical URL for public-bucket attachment redirect", async () => {
75+
const team = await buildTeam();
76+
const id = randomUUID();
77+
const attachment = await buildAttachment({
78+
id,
79+
teamId: team.id,
80+
key: `avatars/${team.id}/${id}/logo.png`,
81+
acl: "public-read",
82+
});
83+
84+
await team.update({
85+
avatarUrl: `/api/attachments.redirect?id=${attachment.id}`,
86+
});
87+
88+
const result = await team.publicAvatarUrl();
89+
expect(result).toEqual(attachment.canonicalUrl);
90+
});
91+
});
4392
});

server/models/Team.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { TeamPreferenceDefaults } from "@shared/constants";
2929
import type { TeamPreferences } from "@shared/types";
3030
import { TeamPreference, UserRole } from "@shared/types";
3131
import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains";
32+
import { attachmentRedirectRegex } from "@shared/utils/ProsemirrorHelper";
3233
import { parseEmail } from "@shared/utils/email";
3334
import { TeamValidation } from "@shared/validations";
3435
import env from "@server/env";
@@ -57,6 +58,8 @@ export enum TeamFlag {
5758
MarkedSafe = "markedSafe",
5859
}
5960

61+
const avatarRedirectPattern = new RegExp(attachmentRedirectRegex.source, "i");
62+
6063
@Scopes(() => ({
6164
withDomains: {
6265
include: [{ model: TeamDomain }],
@@ -145,6 +148,37 @@ class Team extends ParanoidModel<
145148
this.setDataValue("avatarUrl", value);
146149
}
147150

151+
/**
152+
* Returns a directly-accessible URL for the team's avatar suitable for use
153+
* in contexts without authentication. Attachment is loaded and a signed (or
154+
* canonical) URL is returned; any other URL is returned unchanged.
155+
*
156+
* @returns A promise resolving to a direct URL, or null when no avatar is set.
157+
*/
158+
async publicAvatarUrl(): Promise<string | null> {
159+
const url = this.avatarUrl;
160+
if (!url) {
161+
return null;
162+
}
163+
164+
const match = avatarRedirectPattern.exec(url);
165+
if (!match?.groups?.id) {
166+
return url;
167+
}
168+
169+
const attachment = await Attachment.findOne({
170+
where: { id: match.groups.id, teamId: this.id },
171+
});
172+
173+
if (!attachment) {
174+
return url;
175+
}
176+
177+
return attachment.isStoredInPublicBucket
178+
? attachment.canonicalUrl
179+
: await attachment.signedUrl;
180+
}
181+
148182
@Default(true)
149183
@Column
150184
sharing: boolean;

server/routes/app.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,9 @@ export const renderShare = async (ctx: Context, next: Next) => {
340340
(publicBranding && team?.description ? team.description : undefined),
341341
content,
342342
shortcutIcon:
343-
publicBranding && team?.avatarUrl ? team.avatarUrl : undefined,
343+
publicBranding && team?.avatarUrl
344+
? (await team.publicAvatarUrl()) ?? undefined
345+
: undefined,
344346
analytics,
345347
isShare: true,
346348
rootShareId,

0 commit comments

Comments
 (0)