feat: handle avatar, first name and last name

This commit is contained in:
Steve Korshakov 2025-08-26 22:17:11 -07:00
parent 86c0a03bfc
commit a5fcf38d6d
8 changed files with 175 additions and 50 deletions

View File

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "UploadedFile" ADD COLUMN "height" INTEGER,
ADD COLUMN "thumbhash" TEXT,
ADD COLUMN "width" INTEGER;

View File

@ -212,6 +212,9 @@ model UploadedFile {
accountId String accountId String
account Account @relation(fields: [accountId], references: [id]) account Account @relation(fields: [accountId], references: [id])
path String path String
width Int?
height Int?
thumbhash String?
reuseKey String? reuseKey String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@ -14,10 +14,6 @@ import { auth } from "@/modules/auth";
import { import {
EventRouter, EventRouter,
ClientConnection, ClientConnection,
SessionScopedConnection,
UserScopedConnection,
MachineScopedConnection,
RecipientFilter,
buildNewSessionUpdate, buildNewSessionUpdate,
buildNewMessageUpdate, buildNewMessageUpdate,
buildUpdateSessionUpdate, buildUpdateSessionUpdate,
@ -26,8 +22,6 @@ import {
buildSessionActivityEphemeral, buildSessionActivityEphemeral,
buildMachineActivityEphemeral, buildMachineActivityEphemeral,
buildUsageEphemeral, buildUsageEphemeral,
buildMachineStatusEphemeral,
buildUpdateAccountGithubUpdate
} from "@/modules/eventRouter"; } from "@/modules/eventRouter";
import { import {
incrementWebSocketConnection, incrementWebSocketConnection,
@ -41,6 +35,8 @@ import {
import { activityCache } from "@/modules/sessionCache"; import { activityCache } from "@/modules/sessionCache";
import { encryptBytes, encryptString } from "@/modules/encrypt"; import { encryptBytes, encryptString } from "@/modules/encrypt";
import { GitHubProfile } from "./types"; import { GitHubProfile } from "./types";
import { uploadImage } from "@/storage/uploadImage";
import { separateName } from "@/utils/separateName";
declare module 'fastify' { declare module 'fastify' {
@ -93,7 +89,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
}); });
} }
}); });
// Add content type parser for webhook endpoints to preserve raw body // Add content type parser for webhook endpoints to preserve raw body
app.addContentTypeParser( app.addContentTypeParser(
'application/json', 'application/json',
@ -110,7 +106,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
} }
} }
); );
app.setValidatorCompiler(validatorCompiler); app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler); app.setSerializerCompiler(serializerCompiler);
const typed = app.withTypeProvider<ZodTypeProvider>(); const typed = app.withTypeProvider<ZodTypeProvider>();
@ -140,9 +136,9 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
const url = request.url; const url = request.url;
const userAgent = request.headers['user-agent'] || 'unknown'; const userAgent = request.headers['user-agent'] || 'unknown';
const ip = request.ip || 'unknown'; const ip = request.ip || 'unknown';
// Log the error with comprehensive context // Log the error with comprehensive context
log({ log({
module: 'fastify-error', module: 'fastify-error',
level: 'error', level: 'error',
method, method,
@ -153,10 +149,10 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
errorCode: error.code, errorCode: error.code,
stack: error.stack stack: error.stack
}, `Unhandled error: ${error.message}`); }, `Unhandled error: ${error.message}`);
// Return appropriate error response // Return appropriate error response
const statusCode = error.statusCode || 500; const statusCode = error.statusCode || 500;
if (statusCode >= 500) { if (statusCode >= 500) {
// Internal server errors - don't expose details // Internal server errors - don't expose details
return reply.code(statusCode).send({ return reply.code(statusCode).send({
@ -179,7 +175,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
const method = request.method; const method = request.method;
const url = request.url; const url = request.url;
const duration = (Date.now() - (request.startTime || Date.now())) / 1000; const duration = (Date.now() - (request.startTime || Date.now())) / 1000;
log({ log({
module: 'fastify-hook-error', module: 'fastify-hook-error',
level: 'error', level: 'error',
@ -196,7 +192,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
app.addHook('preHandler', async (request, reply) => { app.addHook('preHandler', async (request, reply) => {
// Store original reply.send to catch errors in response serialization // Store original reply.send to catch errors in response serialization
const originalSend = reply.send.bind(reply); const originalSend = reply.send.bind(reply);
reply.send = function(payload: any) { reply.send = function (payload: any) {
try { try {
return originalSend(payload); return originalSend(payload);
} catch (error: any) { } catch (error: any) {
@ -482,15 +478,28 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
} }
}); });
// Avatar
const image = await fetch(userData.avatar_url);
const imageBuffer = await image.arrayBuffer();
const avatar = await uploadImage(userId, 'avatars', 'github', userData.avatar_url, Buffer.from(imageBuffer));
// Name
const name = separateName(userData.name);
// Link GitHub user to account // Link GitHub user to account
await db.account.update({ await db.account.update({
where: { id: userId }, where: { id: userId },
data: { githubUserId: githubUser.id } data: { githubUserId: githubUser.id, avatar, firstName: name.firstName, lastName: name.lastName }
}); });
// Send account update to all user connections // Send account update to all user connections
const updSeq = await allocateUserSeq(userId); const updSeq = await allocateUserSeq(userId);
const updatePayload = buildUpdateAccountGithubUpdate(userId, userData, updSeq, randomKeyNaked(12)); const updatePayload = buildUpdateAccountUpdate(userId, {
github: userData,
firstName: name.firstName,
lastName: name.lastName,
avatar: avatar
}, updSeq, randomKeyNaked(12));
eventRouter.emitUpdate({ eventRouter.emitUpdate({
userId, userId,
payload: updatePayload, payload: updatePayload,
@ -528,22 +537,22 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
const eventName = request.headers['x-github-event']; const eventName = request.headers['x-github-event'];
const deliveryId = request.headers['x-github-delivery']; const deliveryId = request.headers['x-github-delivery'];
const rawBody = (request as any).rawBody; const rawBody = (request as any).rawBody;
if (!rawBody) { if (!rawBody) {
log({ module: 'github-webhook', level: 'error' }, log({ module: 'github-webhook', level: 'error' },
'Raw body not available for webhook signature verification'); 'Raw body not available for webhook signature verification');
return reply.code(500).send({ error: 'Server configuration error' }); return reply.code(500).send({ error: 'Server configuration error' });
} }
// Get the webhooks handler // Get the webhooks handler
const { getWebhooks } = await import("@/modules/github"); const { getWebhooks } = await import("@/modules/github");
const webhooks = getWebhooks(); const webhooks = getWebhooks();
if (!webhooks) { if (!webhooks) {
log({ module: 'github-webhook', level: 'error' }, log({ module: 'github-webhook', level: 'error' },
'GitHub webhooks not initialized'); 'GitHub webhooks not initialized');
return reply.code(500).send({ error: 'Webhooks not configured' }); return reply.code(500).send({ error: 'Webhooks not configured' });
} }
try { try {
// Verify and handle the webhook with type safety // Verify and handle the webhook with type safety
await webhooks.verifyAndReceive({ await webhooks.verifyAndReceive({
@ -552,34 +561,34 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
payload: typeof rawBody === 'string' ? rawBody : JSON.stringify(request.body), payload: typeof rawBody === 'string' ? rawBody : JSON.stringify(request.body),
signature: signature signature: signature
}); });
// Log successful processing // Log successful processing
log({ log({
module: 'github-webhook', module: 'github-webhook',
event: eventName, event: eventName,
delivery: deliveryId delivery: deliveryId
}, `Successfully processed ${eventName} webhook`); }, `Successfully processed ${eventName} webhook`);
return reply.send({ received: true }); return reply.send({ received: true });
} catch (error: any) { } catch (error: any) {
if (error.message?.includes('signature does not match')) { if (error.message?.includes('signature does not match')) {
log({ log({
module: 'github-webhook', module: 'github-webhook',
level: 'warn', level: 'warn',
event: eventName, event: eventName,
delivery: deliveryId delivery: deliveryId
}, 'Invalid webhook signature'); }, 'Invalid webhook signature');
return reply.code(401).send({ error: 'Invalid signature' }); return reply.code(401).send({ error: 'Invalid signature' });
} }
log({ log({
module: 'github-webhook', module: 'github-webhook',
level: 'error', level: 'error',
event: eventName, event: eventName,
delivery: deliveryId delivery: deliveryId
}, `Error processing webhook: ${error.message}`); }, `Error processing webhook: ${error.message}`);
return reply.code(500).send({ error: 'Internal server error' }); return reply.code(500).send({ error: 'Internal server error' });
} }
}); });
@ -1112,7 +1121,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
}; };
// Send account update to all user connections // Send account update to all user connections
const updatePayload = buildUpdateAccountUpdate(userId, settingsUpdate, updSeq, randomKeyNaked(12)); const updatePayload = buildUpdateAccountUpdate(userId, { settings: settingsUpdate }, updSeq, randomKeyNaked(12));
eventRouter.emitUpdate({ eventRouter.emitUpdate({
userId, userId,
payload: updatePayload, payload: updatePayload,

View File

@ -1,6 +1,7 @@
import { Socket } from "socket.io"; import { Socket } from "socket.io";
import { log } from "@/utils/log"; import { log } from "@/utils/log";
import { GitHubProfile } from "@/app/types"; import { GitHubProfile } from "@/app/types";
import { AccountProfile } from "@/types";
// === CONNECTION TYPES === // === CONNECTION TYPES ===
@ -330,27 +331,14 @@ export function buildUpdateSessionUpdate(sessionId: string, updateSeq: number, u
}; };
} }
export function buildUpdateAccountUpdate(userId: string, settings: { value: string | null; version: number }, updateSeq: number, updateId: string): UpdatePayload { export function buildUpdateAccountUpdate(userId: string, profile: Partial<AccountProfile>, updateSeq: number, updateId: string): UpdatePayload {
return { return {
id: updateId, id: updateId,
seq: updateSeq, seq: updateSeq,
body: { body: {
t: 'update-account', t: 'update-account',
id: userId, id: userId,
settings ...profile
},
createdAt: Date.now()
};
}
export function buildUpdateAccountGithubUpdate(userId: string, github: GitHubProfile | null, updateSeq: number, updateId: string): UpdatePayload {
return {
id: updateId,
seq: updateSeq,
body: {
t: 'update-account',
id: userId,
github
}, },
createdAt: Date.now() createdAt: Date.now()
}; };

View File

@ -3,7 +3,25 @@ import { processImage } from "./processImage";
import { s3bucket, s3client, s3host } from "./files"; import { s3bucket, s3client, s3host } from "./files";
import { db } from "./db"; import { db } from "./db";
export async function uploadImage(userId: string, directory: string, prefix: string, src: Buffer) { export async function uploadImage(userId: string, directory: string, prefix: string, url: string, src: Buffer) {
// Check if image already exists
const existing = await db.uploadedFile.findFirst({
where: {
reuseKey: 'image-url:' + url
}
});
if (existing && existing.thumbhash && existing.width && existing.height) {
return {
path: existing.path,
thumbhash: existing.thumbhash,
width: existing.width,
height: existing.height
};
}
// Process image
const processed = await processImage(src); const processed = await processImage(src);
const key = randomKey(prefix); const key = randomKey(prefix);
let filename = `${key}.${processed.format === 'png' ? 'png' : 'jpg'}`; let filename = `${key}.${processed.format === 'png' ? 'png' : 'jpg'}`;
@ -11,7 +29,11 @@ export async function uploadImage(userId: string, directory: string, prefix: str
await db.uploadedFile.create({ await db.uploadedFile.create({
data: { data: {
accountId: userId, accountId: userId,
path: `user/${userId}/${directory}/${filename}` path: `user/${userId}/${directory}/${filename}`,
reuseKey: 'image-url:' + url,
width: processed.width,
height: processed.height,
thumbhash: processed.thumbhash
} }
}); });
return { return {

13
sources/types.ts Normal file
View File

@ -0,0 +1,13 @@
import { GitHubProfile } from "./app/types";
import { ImageRef } from "./storage/files";
export type AccountProfile = {
firstName: string | null;
lastName: string | null;
avatar: ImageRef | null;
github: GitHubProfile | null;
settings: {
value: string | null;
version: number;
} | null;
}

View File

@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { separateName } from './separateName';
describe('separateName', () => {
it('should separate basic first and last name', () => {
const result = separateName('John Doe');
expect(result).toEqual({ firstName: 'John', lastName: 'Doe' });
});
it('should handle single name with no last name', () => {
const result = separateName('John');
expect(result).toEqual({ firstName: 'John', lastName: null });
});
it('should handle multiple names putting everything after first as lastName', () => {
const result = separateName('John William Doe Smith');
expect(result).toEqual({ firstName: 'John', lastName: 'William Doe Smith' });
});
it('should handle empty string', () => {
const result = separateName('');
expect(result).toEqual({ firstName: null, lastName: null });
});
it('should handle null input', () => {
const result = separateName(null);
expect(result).toEqual({ firstName: null, lastName: null });
});
it('should handle undefined input', () => {
const result = separateName(undefined);
expect(result).toEqual({ firstName: null, lastName: null });
});
it('should handle whitespace-only string', () => {
const result = separateName(' ');
expect(result).toEqual({ firstName: null, lastName: null });
});
it('should handle extra spaces between names', () => {
const result = separateName(' John Doe ');
expect(result).toEqual({ firstName: 'John', lastName: 'Doe' });
});
it('should handle names with special characters', () => {
const result = separateName('José María');
expect(result).toEqual({ firstName: 'José', lastName: 'María' });
});
it('should handle hyphenated last names', () => {
const result = separateName('Mary Smith-Johnson');
expect(result).toEqual({ firstName: 'Mary', lastName: 'Smith-Johnson' });
});
it('should handle multiple middle names and hyphenated last name', () => {
const result = separateName('John Michael Robert Smith-Johnson');
expect(result).toEqual({ firstName: 'John', lastName: 'Michael Robert Smith-Johnson' });
});
});

View File

@ -0,0 +1,27 @@
interface NameParts {
firstName: string | null;
lastName: string | null;
}
export function separateName(fullName: string | null | undefined): NameParts {
if (!fullName || typeof fullName !== 'string') {
return { firstName: null, lastName: null };
}
const trimmedName = fullName.trim();
if (!trimmedName) {
return { firstName: null, lastName: null };
}
const parts = trimmedName.split(/\s+/);
if (parts.length === 1) {
return { firstName: parts[0], lastName: null };
}
const firstName = parts[0];
const lastName = parts.slice(1).join(' ');
return { firstName, lastName };
}