feat(gateway): upload user icons to image service

This commit is contained in:
2026-08-06 15:40:55 +00:00
parent 3be3e3b307
commit 57800d8574
9 changed files with 225 additions and 18 deletions
+17 -6
View File
@@ -157,6 +157,12 @@ const buildCaller = (
}
const passwordEnvelope = createPasswordEnvelopeService();
const requestHeaders: Record<string, string> = {};
const userIconUpload = {
upload: vi.fn(async ({ filename }: { filename: string }) => ({
picture: `users/core2026/${filename}`,
publicUrl: `https://sam-image.hided.net/icons/users/core2026/${filename}`,
})),
};
const sealPassword = (password: string) => {
const key = passwordEnvelope.getPublicKey();
return {
@@ -183,6 +189,8 @@ const buildCaller = (
publicBaseUrl: 'http://localhost',
userIconDir: options.userIconDir,
userIconPublicUrl: 'http://localhost/user-icons',
sharedIconPublicUrl: 'https://sam-image.hided.net/icons',
userIconUpload,
adminLocalAccountEnabled: false,
localRegistrationEnabled: true,
localAccountGraceDays: options.localAccountGraceDays ?? 7,
@@ -204,6 +212,7 @@ const buildCaller = (
users,
sessions,
flushPublisher,
userIconUpload,
sealPassword,
setSessionHeader: (sessionToken: string) => {
requestHeaders['x-session-token'] = sessionToken;
@@ -686,7 +695,7 @@ describe('account self service', () => {
it('validates and stores a legacy-sized account icon with a daily change limit', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-'));
try {
const { caller, users, sessions, flushPublisher } = buildCaller({
const { caller, users, sessions, flushPublisher, userIconUpload } = buildCaller({
userIconDir: iconDir,
});
const user = await users.createUser({
@@ -711,11 +720,13 @@ describe('account self service', () => {
});
const updated = await users.findById(user.id);
expect(result.iconUrl).toMatch(/^http:\/\/localhost\/user-icons\/[a-f0-9]{16}\.png$/);
expect(result.iconUrl).toMatch(/^https:\/\/sam-image\.hided\.net\/icons\/users\/core2026\/[a-f0-9]{16}\.png$/);
expect(result.profiles.map((profile) => profile.profileName)).toEqual(['che:default', 'hwe:default']);
expect(updated?.imageServer).toBe(1);
expect(updated?.imageServer).toBe(0);
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-changed');
expect(await fs.stat(path.join(iconDir, updated?.picture ?? 'missing'))).toBeTruthy();
expect(userIconUpload.upload).toHaveBeenCalledWith(
expect.objectContaining({ contentType: 'image/png', body: png })
);
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
code: 'TOO_MANY_REQUESTS',
});
@@ -754,7 +765,7 @@ describe('account self service', () => {
expect(attempts.filter(({ status }) => status === 'fulfilled')).toHaveLength(1);
expect(attempts.filter(({ status }) => status === 'rejected')).toHaveLength(1);
expect(await fs.readdir(iconDir)).toHaveLength(1);
expect(await users.listIcons(user.id)).toHaveLength(1);
} finally {
await fs.rm(iconDir, { recursive: true, force: true });
}
@@ -893,7 +904,7 @@ describe('account self service', () => {
{
projection: {
revision: changed.revision,
imageServer: 1,
imageServer: 0,
},
profiles: [{ profileName: 'che:default' }, { profileName: 'hwe:default' }],
}
@@ -0,0 +1,61 @@
import { createHash, createHmac } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import { RemoteUserIconStore } from '../src/account/remoteUserIconStore.js';
describe('remote user icon store', () => {
it('uses a short-lived path and body-bound HMAC without sending the shared secret', async () => {
const body = Buffer.from('icon-body');
const secret = 'u'.repeat(32);
let captured: { input: string | URL | Request; init?: RequestInit } | undefined;
const fetchImpl: typeof fetch = async (input, init) => {
captured = { input, init };
return new Response(JSON.stringify({ path: `icons/users/core2026/${'a'.repeat(32)}.png` }), {
status: 201,
});
};
const store = new RemoteUserIconStore(
'https://sam-image.hided.net/',
'https://sam-image.hided.net/icons/',
secret,
fetchImpl,
() => Date.parse('2026-08-06T00:00:00.000Z')
);
const result = await store.upload({
filename: `${'a'.repeat(32)}.png`,
contentType: 'image/png',
body,
});
expect(result).toEqual({
picture: `users/core2026/${'a'.repeat(32)}.png`,
publicUrl: `https://sam-image.hided.net/icons/users/core2026/${'a'.repeat(32)}.png`,
});
expect(String(captured?.input)).toBe(
`https://sam-image.hided.net/v1/uploads/user-icons/core2026/${'a'.repeat(32)}.png`
);
const headers = captured?.init?.headers as Record<string, string>;
expect(headers['x-image-expires']).toBe(String(Date.parse('2026-08-06T00:01:00.000Z') / 1000));
expect(Object.values(headers)).not.toContain(secret);
const pathname = `/v1/uploads/user-icons/core2026/${'a'.repeat(32)}.png`;
const digest = createHash('sha256').update(body).digest('hex');
const expected = createHmac('sha256', secret)
.update(
`${headers['x-image-expires']}.${headers['x-image-request-id']}.${pathname}.image/png.${digest}`
)
.digest('hex');
expect(headers['x-image-signature']).toBe(expected);
});
it('does not return a picture when the image service rejects the grant', async () => {
const store = new RemoteUserIconStore(
'https://sam-image.hided.net',
'https://sam-image.hided.net/icons',
'u'.repeat(32),
async () => new Response('{}', { status: 401 })
);
await expect(
store.upload({ filename: `${'b'.repeat(32)}.png`, contentType: 'image/png', body: Buffer.from('x') })
).rejects.toThrow('HTTP 401');
});
});