Skip to content

Commit d8dfa54

Browse files
committed
fix(favicon): reuse auth and fallback to site avatar
1 parent 74098b1 commit d8dfa54

3 files changed

Lines changed: 118 additions & 2 deletions

File tree

client/src/page/settings-helpers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ConfigWrapper } from "@rin/config";
22
import type { TFunction } from "i18next";
33
import { client, endpoint } from "../app/runtime";
44
import { defaultClientConfig, defaultServerConfig } from "../state/config";
5+
import { headersWithAuth } from "../utils/auth";
56

67
const MASKED_SECRET = "••••••••";
78

@@ -87,6 +88,7 @@ export async function uploadFavicon(file: File, t: TFunction, showAlert: (messag
8788
formData.append("file", file);
8889
const response = await fetch(`${endpoint}/api/favicon`, {
8990
method: "POST",
91+
headers: headersWithAuth(),
9092
body: formData,
9193
credentials: "include",
9294
});

server/src/services/__tests__/favicon.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,64 @@ describe('FaviconService', () => {
139139
// Will fail due to S3 not being available, but verifies route is registered
140140
expect(res.status).not.toBe(404);
141141
});
142+
143+
it('should generate favicon from site avatar when favicon is missing', async () => {
144+
const clientConfig = new TestCacheImpl();
145+
await clientConfig.set('site.avatar', 'https://example.com/avatar.png');
146+
147+
app = new Hono<{ Bindings: Env; Variables: Variables }>();
148+
app.use(createMiddleware<{ Bindings: Env; Variables: Variables }>(async (c, next) => {
149+
c.set('db', db);
150+
c.set('cache', new TestCacheImpl());
151+
c.set('serverConfig', new TestCacheImpl());
152+
c.set('clientConfig', clientConfig);
153+
c.set('jwt', {
154+
sign: async (payload: any) => `mock_token_${payload.id}`,
155+
verify: async (token: string) => token.startsWith('mock_token_') ? { id: 1 } : null,
156+
} as JWTUtils);
157+
c.set('oauth2', undefined);
158+
c.set('env', env);
159+
c.set('uid', 1);
160+
c.set('admin', true);
161+
await next();
162+
}));
163+
app.route('/', FaviconService());
164+
165+
const originalFetch = globalThis.fetch;
166+
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
167+
const url = typeof input === 'string'
168+
? input
169+
: input instanceof URL
170+
? input.toString()
171+
: input.url;
172+
const method = init?.method ?? (input instanceof Request ? input.method : undefined);
173+
174+
if (url === 'https://example.com/avatar.png') {
175+
return new Response(new Uint8Array([1, 2, 3]), {
176+
status: 200,
177+
headers: { 'Content-Type': 'image/webp' },
178+
});
179+
}
180+
181+
if (url === 'https://test-bucket.test.r2.cloudflarestorage.com/images/favicon.webp' && method === 'PUT') {
182+
return new Response(null, { status: 200 });
183+
}
184+
185+
if (url.endsWith('/images/favicon.webp')) {
186+
return new Response('missing', { status: 404 });
187+
}
188+
189+
return originalFetch(input, init);
190+
};
191+
192+
try {
193+
const res = await app.request('/', { method: 'GET' }, env);
194+
expect(res.status).toBe(200);
195+
expect(res.headers.get('content-type')).toBe('image/webp');
196+
} finally {
197+
globalThis.fetch = originalFetch;
198+
}
199+
});
142200
});
143201

144202
describe('GET /original - Get original favicon', () => {

server/src/services/favicon.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,77 @@ export function getFaviconKey(env: Env) {
1515
return path_join(env.S3_FOLDER || "", "favicon.webp");
1616
}
1717

18+
async function buildFaviconFromSource(c: AppContext, sourceUrl: string, faviconKey: string) {
19+
const env = c.get('env');
20+
const s3 = createS3Client(env);
21+
const imageRequest = new Request(sourceUrl, {
22+
headers: c.req.raw.headers,
23+
});
24+
25+
const response = await fetch(imageRequest, {
26+
cf: {
27+
image: {
28+
width: 144,
29+
height: 144,
30+
fit: "cover",
31+
format: "webp",
32+
quality: 100,
33+
},
34+
},
35+
});
36+
37+
if (!response.ok) {
38+
return response;
39+
}
40+
41+
const arrayBuffer = await response.arrayBuffer();
42+
await putObject(
43+
s3,
44+
env,
45+
faviconKey,
46+
new Uint8Array(arrayBuffer),
47+
"image/webp",
48+
);
49+
50+
return new Response(arrayBuffer, {
51+
status: 200,
52+
headers: {
53+
"Content-Type": "image/webp",
54+
"Cache-Control": "public, max-age=31536000",
55+
},
56+
});
57+
}
58+
1859
export function FaviconService(): Hono {
1960
const app = new Hono();
2061

2162
// GET /favicon
2263
app.get("/", async (c: AppContext) => {
2364
const env = c.get('env');
65+
const clientConfig = c.get('clientConfig');
2466
const accessHost = env.S3_ACCESS_HOST || env.S3_ENDPOINT;
2567
const faviconKey = getFaviconKey(env);
2668

2769
try {
2870
const response = await fetch(new Request(`${accessHost}/${faviconKey}`));
2971

3072
if (!response.ok) {
31-
c.status(response.status as 200 | 400 | 401 | 403 | 404 | 500);
32-
return c.text(await response.text());
73+
const avatar = await clientConfig.get("site.avatar") as string | undefined;
74+
if (!avatar) {
75+
c.status(response.status as 200 | 400 | 401 | 403 | 404 | 500);
76+
return c.text(await response.text());
77+
}
78+
79+
const avatarUrl = new URL(avatar, c.req.url).toString();
80+
const generatedFavicon = await buildFaviconFromSource(c, avatarUrl, faviconKey);
81+
if (!generatedFavicon.ok) {
82+
c.status(generatedFavicon.status as 200 | 400 | 401 | 403 | 404 | 500);
83+
return c.text(await generatedFavicon.text());
84+
}
85+
86+
c.header("Content-Type", generatedFavicon.headers.get("Content-Type") || "image/webp");
87+
c.header("Cache-Control", generatedFavicon.headers.get("Cache-Control") || "public, max-age=31536000");
88+
return c.body(await generatedFavicon.arrayBuffer());
3389
}
3490

3591
c.header("Content-Type", "image/webp");

0 commit comments

Comments
 (0)