diff --git a/prisma/migrations/20250827051355_add_image_parameters/migration.sql b/prisma/migrations/20250827051355_add_image_parameters/migration.sql new file mode 100644 index 0000000..ee6482e --- /dev/null +++ b/prisma/migrations/20250827051355_add_image_parameters/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "UploadedFile" ADD COLUMN "height" INTEGER, +ADD COLUMN "thumbhash" TEXT, +ADD COLUMN "width" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dc3ba3c..a0405db 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -212,6 +212,9 @@ model UploadedFile { accountId String account Account @relation(fields: [accountId], references: [id]) path String + width Int? + height Int? + thumbhash String? reuseKey String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/sources/app/api.ts b/sources/app/api.ts index d27ace3..e72ead3 100644 --- a/sources/app/api.ts +++ b/sources/app/api.ts @@ -14,10 +14,6 @@ import { auth } from "@/modules/auth"; import { EventRouter, ClientConnection, - SessionScopedConnection, - UserScopedConnection, - MachineScopedConnection, - RecipientFilter, buildNewSessionUpdate, buildNewMessageUpdate, buildUpdateSessionUpdate, @@ -26,8 +22,6 @@ import { buildSessionActivityEphemeral, buildMachineActivityEphemeral, buildUsageEphemeral, - buildMachineStatusEphemeral, - buildUpdateAccountGithubUpdate } from "@/modules/eventRouter"; import { incrementWebSocketConnection, @@ -41,6 +35,8 @@ import { import { activityCache } from "@/modules/sessionCache"; import { encryptBytes, encryptString } from "@/modules/encrypt"; import { GitHubProfile } from "./types"; +import { uploadImage } from "@/storage/uploadImage"; +import { separateName } from "@/utils/separateName"; 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 app.addContentTypeParser( 'application/json', @@ -110,7 +106,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }> } } ); - + app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler); const typed = app.withTypeProvider(); @@ -140,9 +136,9 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }> const url = request.url; const userAgent = request.headers['user-agent'] || 'unknown'; const ip = request.ip || 'unknown'; - + // Log the error with comprehensive context - log({ + log({ module: 'fastify-error', level: 'error', method, @@ -153,10 +149,10 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }> errorCode: error.code, stack: error.stack }, `Unhandled error: ${error.message}`); - + // Return appropriate error response const statusCode = error.statusCode || 500; - + if (statusCode >= 500) { // Internal server errors - don't expose details return reply.code(statusCode).send({ @@ -179,7 +175,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }> const method = request.method; const url = request.url; const duration = (Date.now() - (request.startTime || Date.now())) / 1000; - + log({ module: 'fastify-hook-error', level: 'error', @@ -196,7 +192,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }> app.addHook('preHandler', async (request, reply) => { // Store original reply.send to catch errors in response serialization const originalSend = reply.send.bind(reply); - reply.send = function(payload: any) { + reply.send = function (payload: any) { try { return originalSend(payload); } 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 await db.account.update({ 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 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({ userId, payload: updatePayload, @@ -528,22 +537,22 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }> const eventName = request.headers['x-github-event']; const deliveryId = request.headers['x-github-delivery']; const rawBody = (request as any).rawBody; - + if (!rawBody) { - log({ module: 'github-webhook', level: 'error' }, + log({ module: 'github-webhook', level: 'error' }, 'Raw body not available for webhook signature verification'); return reply.code(500).send({ error: 'Server configuration error' }); } - + // Get the webhooks handler const { getWebhooks } = await import("@/modules/github"); const webhooks = getWebhooks(); if (!webhooks) { - log({ module: 'github-webhook', level: 'error' }, + log({ module: 'github-webhook', level: 'error' }, 'GitHub webhooks not initialized'); return reply.code(500).send({ error: 'Webhooks not configured' }); } - + try { // Verify and handle the webhook with type safety 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), signature: signature }); - + // Log successful processing - log({ + log({ module: 'github-webhook', event: eventName, - delivery: deliveryId + delivery: deliveryId }, `Successfully processed ${eventName} webhook`); - + return reply.send({ received: true }); - + } catch (error: any) { if (error.message?.includes('signature does not match')) { - log({ - module: 'github-webhook', + log({ + module: 'github-webhook', level: 'warn', event: eventName, delivery: deliveryId }, 'Invalid webhook signature'); return reply.code(401).send({ error: 'Invalid signature' }); } - - log({ - module: 'github-webhook', + + log({ + module: 'github-webhook', level: 'error', event: eventName, delivery: deliveryId }, `Error processing webhook: ${error.message}`); - + 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 - const updatePayload = buildUpdateAccountUpdate(userId, settingsUpdate, updSeq, randomKeyNaked(12)); + const updatePayload = buildUpdateAccountUpdate(userId, { settings: settingsUpdate }, updSeq, randomKeyNaked(12)); eventRouter.emitUpdate({ userId, payload: updatePayload, diff --git a/sources/modules/eventRouter.ts b/sources/modules/eventRouter.ts index b8e0377..691a8b4 100644 --- a/sources/modules/eventRouter.ts +++ b/sources/modules/eventRouter.ts @@ -1,6 +1,7 @@ import { Socket } from "socket.io"; import { log } from "@/utils/log"; import { GitHubProfile } from "@/app/types"; +import { AccountProfile } from "@/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, updateSeq: number, updateId: string): UpdatePayload { return { id: updateId, seq: updateSeq, body: { t: 'update-account', id: userId, - settings - }, - 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 + ...profile }, createdAt: Date.now() }; diff --git a/sources/storage/uploadImage.ts b/sources/storage/uploadImage.ts index de7fe49..f00e5a2 100644 --- a/sources/storage/uploadImage.ts +++ b/sources/storage/uploadImage.ts @@ -3,7 +3,25 @@ import { processImage } from "./processImage"; import { s3bucket, s3client, s3host } from "./files"; 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 key = randomKey(prefix); 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({ data: { 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 { diff --git a/sources/types.ts b/sources/types.ts new file mode 100644 index 0000000..eab462f --- /dev/null +++ b/sources/types.ts @@ -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; +} \ No newline at end of file diff --git a/sources/utils/separateName.spec.ts b/sources/utils/separateName.spec.ts new file mode 100644 index 0000000..e3cbbd5 --- /dev/null +++ b/sources/utils/separateName.spec.ts @@ -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' }); + }); +}); \ No newline at end of file diff --git a/sources/utils/separateName.ts b/sources/utils/separateName.ts new file mode 100644 index 0000000..16ceb77 --- /dev/null +++ b/sources/utils/separateName.ts @@ -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 }; +} \ No newline at end of file