ref: extract github connect and disconnect to a separate action, shared eventRouter, copy username to the account on connect and remove on disconnect, make github profile unique
This commit is contained in:
parent
79b97c1b88
commit
d032ec1596
@ -269,4 +269,11 @@ tail -500 .logs/*.log | grep "applySessions.*active" | tail -10
|
||||
- **CLI logs**: `[HH:MM:SS.mmm]` in local time (e.g., `[13:45:23.738]`)
|
||||
- **Server logs**: Include both `time` (Unix ms) and `localTime` (HH:MM:ss.mmm)
|
||||
- **Mobile logs**: Sent with `timestamp` in UTC, converted to `localTime` on server
|
||||
- **All consolidated logs**: Have `localTime` field for easy correlation
|
||||
- **All consolidated logs**: Have `localTime` field for easy correlation
|
||||
- When writing a some operations on db, like adding friend, sending a notification - always create a dedicated file in relevant subfolder of the @sources/app/ folder. Good example is "friendAdd", always prefix with an entity type, then action that should be performed.
|
||||
- Never create migrations yourself, it is can be done only by human
|
||||
- Do not return stuff from action functions "just in case", only essential
|
||||
- Do not add logging when not asked
|
||||
- do not run non-transactional things (like uploadign files) in transactions
|
||||
- After writing an action - add a documentation comment that explains logic, also keep it in sync.
|
||||
- always use github usernames
|
11
prisma/migrations/20250920025406_add_username/migration.sql
Normal file
11
prisma/migrations/20250920025406_add_username/migration.sql
Normal file
@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[username]` on the table `Account` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Account" ADD COLUMN "username" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Account_username_key" ON "Account"("username");
|
@ -33,6 +33,7 @@ model Account {
|
||||
// Profile
|
||||
firstName String?
|
||||
lastName String?
|
||||
username String? @unique
|
||||
/// [ImageRef]
|
||||
avatar Json?
|
||||
|
||||
|
@ -2,7 +2,6 @@ import fastify from "fastify";
|
||||
import { log, logger } from "@/utils/log";
|
||||
import { serializerCompiler, validatorCompiler, ZodTypeProvider } from "fastify-type-provider-zod";
|
||||
import { onShutdown } from "@/utils/shutdown";
|
||||
import { EventRouter } from "@/app/events/eventRouter";
|
||||
import { Fastify } from "./types";
|
||||
import { authRoutes } from "./routes/authRoutes";
|
||||
import { pushRoutes } from "./routes/pushRoutes";
|
||||
@ -21,7 +20,7 @@ import { enableErrorHandlers } from "./utils/enableErrorHandlers";
|
||||
import { enableAuthentication } from "./utils/enableAuthentication";
|
||||
import { userRoutes } from "./routes/userRoutes";
|
||||
|
||||
export async function startApi(eventRouter: EventRouter) {
|
||||
export async function startApi() {
|
||||
|
||||
// Configure
|
||||
log('Starting API...');
|
||||
@ -53,12 +52,12 @@ export async function startApi(eventRouter: EventRouter) {
|
||||
// Routes
|
||||
authRoutes(typed);
|
||||
pushRoutes(typed);
|
||||
sessionRoutes(typed, eventRouter);
|
||||
accountRoutes(typed, eventRouter);
|
||||
connectRoutes(typed, eventRouter);
|
||||
machinesRoutes(typed, eventRouter);
|
||||
artifactsRoutes(typed, eventRouter);
|
||||
accessKeysRoutes(typed, eventRouter);
|
||||
sessionRoutes(typed);
|
||||
accountRoutes(typed);
|
||||
connectRoutes(typed);
|
||||
machinesRoutes(typed);
|
||||
artifactsRoutes(typed);
|
||||
accessKeysRoutes(typed);
|
||||
devRoutes(typed);
|
||||
versionRoutes(typed);
|
||||
voiceRoutes(typed);
|
||||
@ -72,7 +71,7 @@ export async function startApi(eventRouter: EventRouter) {
|
||||
});
|
||||
|
||||
// Start Socket
|
||||
startSocket(typed, eventRouter);
|
||||
startSocket(typed);
|
||||
|
||||
// End
|
||||
log('API ready on port http://localhost:' + port);
|
||||
|
@ -2,9 +2,8 @@ import { Fastify } from "../types";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/storage/db";
|
||||
import { log } from "@/utils/log";
|
||||
import { EventRouter } from "@/app/events/eventRouter";
|
||||
|
||||
export function accessKeysRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||
export function accessKeysRoutes(app: Fastify) {
|
||||
// Get Access Key API
|
||||
app.get('/v1/access-keys/:sessionId/:machineId', {
|
||||
preHandler: app.authenticate,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { EventRouter, buildUpdateAccountUpdate } from "@/app/events/eventRouter";
|
||||
import { eventRouter, buildUpdateAccountUpdate } from "@/app/events/eventRouter";
|
||||
import { db } from "@/storage/db";
|
||||
import { Fastify } from "../types";
|
||||
import { getPublicUrl } from "@/storage/files";
|
||||
@ -8,7 +8,7 @@ import { allocateUserSeq } from "@/storage/seq";
|
||||
import { log } from "@/utils/log";
|
||||
import { AccountProfile } from "@/types";
|
||||
|
||||
export function accountRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||
export function accountRoutes(app: Fastify) {
|
||||
app.get('/v1/account/profile', {
|
||||
preHandler: app.authenticate,
|
||||
}, async (request, reply) => {
|
||||
@ -18,6 +18,7 @@ export function accountRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
githubUser: true
|
||||
}
|
||||
@ -28,6 +29,7 @@ export function accountRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||
timestamp: Date.now(),
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
username: user.username,
|
||||
avatar: user.avatar ? { ...user.avatar, url: getPublicUrl(user.avatar.path) } : null,
|
||||
github: user.githubUser ? user.githubUser.profile : null,
|
||||
connectedServices: Array.from(connectedVendors)
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { EventRouter, buildNewArtifactUpdate, buildUpdateArtifactUpdate, buildDeleteArtifactUpdate } from "@/app/events/eventRouter";
|
||||
import { eventRouter, buildNewArtifactUpdate, buildUpdateArtifactUpdate, buildDeleteArtifactUpdate } from "@/app/events/eventRouter";
|
||||
import { db } from "@/storage/db";
|
||||
import { Fastify } from "../types";
|
||||
import { z } from "zod";
|
||||
@ -7,7 +7,7 @@ import { allocateUserSeq } from "@/storage/seq";
|
||||
import { log } from "@/utils/log";
|
||||
import * as privacyKit from "privacy-kit";
|
||||
|
||||
export function artifactsRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||
export function artifactsRoutes(app: Fastify) {
|
||||
// GET /v1/artifacts - List all artifacts for the account
|
||||
app.get('/v1/artifacts', {
|
||||
preHandler: app.authenticate,
|
||||
|
@ -1,19 +1,15 @@
|
||||
import { z } from "zod";
|
||||
import { type Fastify } from "../types";
|
||||
import { type Fastify, GitHubProfile } from "../types";
|
||||
import { auth } from "@/app/auth/auth";
|
||||
import { log } from "@/utils/log";
|
||||
import { db } from "@/storage/db";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { allocateUserSeq } from "@/storage/seq";
|
||||
import { randomKeyNaked } from "@/utils/randomKeyNaked";
|
||||
import { buildUpdateAccountUpdate } from "@/app/events/eventRouter";
|
||||
import { GitHubProfile } from "../types";
|
||||
import { separateName } from "@/utils/separateName";
|
||||
import { uploadImage } from "@/storage/uploadImage";
|
||||
import { EventRouter } from "@/app/events/eventRouter";
|
||||
import { eventRouter } from "@/app/events/eventRouter";
|
||||
import { decryptString, encryptString } from "@/modules/encrypt";
|
||||
import { githubConnect } from "@/app/github/githubConnect";
|
||||
import { githubDisconnect } from "@/app/github/githubDisconnect";
|
||||
import { Context } from "@/context";
|
||||
import { db } from "@/storage/db";
|
||||
|
||||
export function connectRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||
export function connectRoutes(app: Fastify) {
|
||||
|
||||
// Add content type parser for webhook endpoints to preserve raw body
|
||||
app.addContentTypeParser(
|
||||
@ -155,53 +151,9 @@ export function connectRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||
return reply.redirect('https://app.happy.engineering?error=github_user_fetch_failed');
|
||||
}
|
||||
|
||||
// Store GitHub user and connect to account
|
||||
const githubUser = await db.githubUser.upsert({
|
||||
where: { id: userData.id.toString() },
|
||||
update: {
|
||||
profile: userData,
|
||||
token: encryptString(['user', userId, 'github', 'token'], accessToken!)
|
||||
},
|
||||
create: {
|
||||
id: userData.id.toString(),
|
||||
profile: userData,
|
||||
token: encryptString(['user', userId, 'github', 'token'], accessToken!)
|
||||
}
|
||||
});
|
||||
|
||||
// Avatar
|
||||
log({ module: 'github-oauth' }, `Uploading avatar for user ${userId}: ${userData.avatar_url}`);
|
||||
const image = await fetch(userData.avatar_url);
|
||||
const imageBuffer = await image.arrayBuffer();
|
||||
log({ module: 'github-oauth' }, `Uploading avatar for user ${userId}: ${userData.avatar_url}`);
|
||||
const avatar = await uploadImage(userId, 'avatars', 'github', userData.avatar_url, Buffer.from(imageBuffer));
|
||||
log({ module: 'github-oauth' }, `Uploaded avatar for user ${userId}: ${userData.avatar_url}`);
|
||||
|
||||
// Name
|
||||
const name = separateName(userData.name);
|
||||
log({ module: 'github-oauth' }, `Separated name for user ${userId}: ${userData.name} -> ${name.firstName} ${name.lastName}`);
|
||||
|
||||
// Link GitHub user to account
|
||||
await db.account.update({
|
||||
where: { id: userId },
|
||||
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 = buildUpdateAccountUpdate(userId, {
|
||||
github: userData,
|
||||
firstName: name.firstName,
|
||||
lastName: name.lastName,
|
||||
avatar: avatar
|
||||
}, updSeq, randomKeyNaked(12));
|
||||
eventRouter.emitUpdate({
|
||||
userId,
|
||||
payload: updatePayload,
|
||||
recipientFilter: { type: 'all-user-authenticated-connections' }
|
||||
});
|
||||
|
||||
log({ module: 'github-oauth' }, `GitHub account connected successfully for user ${userId}: ${userData.login}`);
|
||||
// Use the new githubConnect operation
|
||||
const ctx = Context.create(userId);
|
||||
await githubConnect(ctx, userData, accessToken!);
|
||||
|
||||
// Redirect to app with success
|
||||
return reply.redirect(`https://app.happy.engineering?github=connected&user=${encodeURIComponent(userData.login)}`);
|
||||
@ -248,42 +200,16 @@ export function connectRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||
return reply.code(500).send({ error: 'Webhooks not configured' });
|
||||
}
|
||||
|
||||
// Verify and handle the webhook with type safety
|
||||
try {
|
||||
// Verify and handle the webhook with type safety
|
||||
await webhooks.verifyAndReceive({
|
||||
id: deliveryId || 'unknown',
|
||||
name: eventName,
|
||||
payload: typeof rawBody === 'string' ? rawBody : JSON.stringify(request.body),
|
||||
signature: signature
|
||||
});
|
||||
|
||||
// Log successful processing
|
||||
log({
|
||||
module: 'github-webhook',
|
||||
event: eventName,
|
||||
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',
|
||||
level: 'warn',
|
||||
event: eventName,
|
||||
delivery: deliveryId
|
||||
}, 'Invalid webhook signature');
|
||||
return reply.code(401).send({ error: 'Invalid signature' });
|
||||
}
|
||||
|
||||
log({
|
||||
module: 'github-webhook',
|
||||
level: 'error',
|
||||
event: eventName,
|
||||
delivery: deliveryId
|
||||
}, `Error processing webhook: ${error.message}`);
|
||||
|
||||
return reply.code(500).send({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
@ -306,56 +232,11 @@ export function connectRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
const userId = request.userId;
|
||||
|
||||
const ctx = Context.create(userId);
|
||||
try {
|
||||
// Get current user's GitHub connection
|
||||
const user = await db.account.findUnique({
|
||||
where: { id: userId },
|
||||
select: { githubUserId: true }
|
||||
});
|
||||
|
||||
if (!user || !user.githubUserId) {
|
||||
return reply.code(404).send({ error: 'GitHub account not connected' });
|
||||
}
|
||||
|
||||
const githubUserId = user.githubUserId;
|
||||
log({ module: 'github-disconnect' }, `Disconnecting GitHub account for user ${userId}: ${githubUserId}`);
|
||||
|
||||
// Remove GitHub connection from account and delete GitHub user record
|
||||
await db.$transaction(async (tx) => {
|
||||
// Remove link from account and clear avatar
|
||||
await tx.account.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
githubUserId: null,
|
||||
avatar: Prisma.JsonNull
|
||||
}
|
||||
});
|
||||
|
||||
// Delete GitHub user record (this also deletes the token)
|
||||
await tx.githubUser.delete({
|
||||
where: { id: githubUserId }
|
||||
});
|
||||
});
|
||||
|
||||
// Send account update to all user connections
|
||||
const updSeq = await allocateUserSeq(userId);
|
||||
const updatePayload = buildUpdateAccountUpdate(userId, {
|
||||
github: null,
|
||||
avatar: null
|
||||
}, updSeq, randomKeyNaked(12));
|
||||
eventRouter.emitUpdate({
|
||||
userId,
|
||||
payload: updatePayload,
|
||||
recipientFilter: { type: 'all-user-authenticated-connections' }
|
||||
});
|
||||
|
||||
log({ module: 'github-disconnect' }, `GitHub account and avatar disconnected successfully for user ${userId}`);
|
||||
|
||||
await githubDisconnect(ctx);
|
||||
return reply.send({ success: true });
|
||||
|
||||
} catch (error) {
|
||||
log({ module: 'github-disconnect', level: 'error' }, `Error disconnecting GitHub account: ${error}`);
|
||||
} catch (error: any) {
|
||||
return reply.code(500).send({ error: 'Failed to disconnect GitHub account' });
|
||||
}
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { EventRouter } from "@/app/events/eventRouter";
|
||||
import { eventRouter } from "@/app/events/eventRouter";
|
||||
import { Fastify } from "../types";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/storage/db";
|
||||
@ -7,7 +7,7 @@ import { randomKeyNaked } from "@/utils/randomKeyNaked";
|
||||
import { allocateUserSeq } from "@/storage/seq";
|
||||
import { buildNewMachineUpdate, buildUpdateMachineUpdate } from "@/app/events/eventRouter";
|
||||
|
||||
export function machinesRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||
export function machinesRoutes(app: Fastify) {
|
||||
app.post('/v1/machines', {
|
||||
preHandler: app.authenticate,
|
||||
schema: {
|
||||
@ -59,7 +59,7 @@ export function machinesRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||
metadataVersion: 1,
|
||||
daemonState: daemonState || null,
|
||||
daemonStateVersion: daemonState ? 1 : 0,
|
||||
dataEncryptionKey: dataEncryptionKey ? Buffer.from(dataEncryptionKey, 'base64') : undefined,
|
||||
dataEncryptionKey: dataEncryptionKey ? new Uint8Array(Buffer.from(dataEncryptionKey, 'base64')) : undefined,
|
||||
// Default to offline - in case the user does not start daemon
|
||||
active: false,
|
||||
// lastActiveAt and activeAt defaults to now() in schema
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { EventRouter, buildNewSessionUpdate } from "@/app/events/eventRouter";
|
||||
import { eventRouter, buildNewSessionUpdate } from "@/app/events/eventRouter";
|
||||
import { type Fastify } from "../types";
|
||||
import { db } from "@/storage/db";
|
||||
import { z } from "zod";
|
||||
@ -7,7 +7,7 @@ import { log } from "@/utils/log";
|
||||
import { randomKeyNaked } from "@/utils/randomKeyNaked";
|
||||
import { allocateUserSeq } from "@/storage/seq";
|
||||
|
||||
export function sessionRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||
export function sessionRoutes(app: Fastify) {
|
||||
|
||||
// Sessions API
|
||||
app.get('/v1/sessions', {
|
||||
|
@ -39,7 +39,7 @@ export async function userRoutes(app: Fastify) {
|
||||
}
|
||||
});
|
||||
|
||||
if (!user || !user.githubUser) {
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: 'User not found' });
|
||||
}
|
||||
|
||||
@ -65,7 +65,7 @@ export async function userRoutes(app: Fastify) {
|
||||
height: user.avatar.height,
|
||||
thumbhash: user.avatar.thumbhash
|
||||
} : null,
|
||||
username: user.githubUser.profile.login,
|
||||
username: user.username || (user.githubUser?.profile?.login || ''),
|
||||
status: status
|
||||
}
|
||||
});
|
||||
@ -90,22 +90,27 @@ export async function userRoutes(app: Fastify) {
|
||||
}, async (request, reply) => {
|
||||
const { query } = request.query;
|
||||
|
||||
// Search for user
|
||||
// Search for user by username or GitHub login
|
||||
const user = await db.account.findFirst({
|
||||
where: {
|
||||
githubUser: {
|
||||
profile: {
|
||||
path: ['login'],
|
||||
equals: query
|
||||
OR: [
|
||||
{ username: query },
|
||||
{
|
||||
githubUser: {
|
||||
profile: {
|
||||
path: ['login'],
|
||||
equals: query
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
include: {
|
||||
githubUser: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user || !user.githubUser) {
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: 'User not found' });
|
||||
}
|
||||
|
||||
@ -130,7 +135,7 @@ export async function userRoutes(app: Fastify) {
|
||||
height: user.avatar.height,
|
||||
thumbhash: user.avatar.thumbhash
|
||||
} : null,
|
||||
username: user.githubUser.profile.login,
|
||||
username: user.username || (user.githubUser?.profile?.login || ''),
|
||||
status: status
|
||||
}
|
||||
});
|
||||
@ -144,7 +149,7 @@ export async function userRoutes(app: Fastify) {
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
user: UserProfileSchema
|
||||
user: UserProfileSchema.nullable()
|
||||
}),
|
||||
404: z.object({
|
||||
error: z.literal('User not found')
|
||||
@ -154,9 +159,6 @@ export async function userRoutes(app: Fastify) {
|
||||
preHandler: app.authenticate
|
||||
}, async (request, reply) => {
|
||||
const user = await friendAdd(Context.create(request.userId), request.body.uid);
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: 'User not found' });
|
||||
}
|
||||
return reply.send({ user });
|
||||
});
|
||||
|
||||
@ -167,7 +169,7 @@ export async function userRoutes(app: Fastify) {
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
user: UserProfileSchema
|
||||
user: UserProfileSchema.nullable()
|
||||
}),
|
||||
404: z.object({
|
||||
error: z.literal('User not found')
|
||||
@ -177,9 +179,6 @@ export async function userRoutes(app: Fastify) {
|
||||
preHandler: app.authenticate
|
||||
}, async (request, reply) => {
|
||||
const user = await friendRemove(Context.create(request.userId), request.body.uid);
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: 'User not found' });
|
||||
}
|
||||
return reply.send({ user });
|
||||
});
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { onShutdown } from "@/utils/shutdown";
|
||||
import { Fastify } from "./types";
|
||||
import { buildMachineActivityEphemeral, ClientConnection, EventRouter } from "@/app/events/eventRouter";
|
||||
import { buildMachineActivityEphemeral, ClientConnection, eventRouter } from "@/app/events/eventRouter";
|
||||
import { Server, Socket } from "socket.io";
|
||||
import { log } from "@/utils/log";
|
||||
import { auth } from "@/app/auth/auth";
|
||||
@ -13,7 +13,7 @@ import { machineUpdateHandler } from "./socket/machineUpdateHandler";
|
||||
import { artifactUpdateHandler } from "./socket/artifactUpdateHandler";
|
||||
import { accessKeyHandler } from "./socket/accessKeyHandler";
|
||||
|
||||
export function startSocket(app: Fastify, eventRouter: EventRouter) {
|
||||
export function startSocket(app: Fastify) {
|
||||
const io = new Server(app.server, {
|
||||
cors: {
|
||||
origin: "*",
|
||||
@ -137,13 +137,13 @@ export function startSocket(app: Fastify, eventRouter: EventRouter) {
|
||||
userRpcListeners = new Map<string, Socket>();
|
||||
rpcListeners.set(userId, userRpcListeners);
|
||||
}
|
||||
rpcHandler(userId, socket, eventRouter, userRpcListeners);
|
||||
usageHandler(userId, socket, eventRouter);
|
||||
sessionUpdateHandler(userId, socket, connection, eventRouter);
|
||||
rpcHandler(userId, socket, userRpcListeners);
|
||||
usageHandler(userId, socket);
|
||||
sessionUpdateHandler(userId, socket, connection);
|
||||
pingHandler(socket);
|
||||
machineUpdateHandler(userId, socket, eventRouter);
|
||||
artifactUpdateHandler(userId, socket, eventRouter);
|
||||
accessKeyHandler(userId, socket, eventRouter);
|
||||
machineUpdateHandler(userId, socket);
|
||||
artifactUpdateHandler(userId, socket);
|
||||
accessKeyHandler(userId, socket);
|
||||
|
||||
// Ready
|
||||
log({ module: 'websocket' }, `User connected: ${userId}`);
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { Socket } from "socket.io";
|
||||
import { db } from "@/storage/db";
|
||||
import { log } from "@/utils/log";
|
||||
import { EventRouter } from "@/app/events/eventRouter";
|
||||
import { eventRouter } from "@/app/events/eventRouter";
|
||||
|
||||
export function accessKeyHandler(userId: string, socket: Socket, eventRouter: EventRouter) {
|
||||
export function accessKeyHandler(userId: string, socket: Socket) {
|
||||
// Get access key via socket
|
||||
socket.on('access-key-get', async (data: { sessionId: string; machineId: string }, callback: (response: any) => void) => {
|
||||
try {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { websocketEventsCounter } from "@/app/monitoring/metrics2";
|
||||
import { buildNewArtifactUpdate, buildUpdateArtifactUpdate, buildDeleteArtifactUpdate, EventRouter } from "@/app/events/eventRouter";
|
||||
import { buildNewArtifactUpdate, buildUpdateArtifactUpdate, buildDeleteArtifactUpdate, eventRouter } from "@/app/events/eventRouter";
|
||||
import { db } from "@/storage/db";
|
||||
import { allocateUserSeq } from "@/storage/seq";
|
||||
import { log } from "@/utils/log";
|
||||
@ -7,7 +7,7 @@ import { randomKeyNaked } from "@/utils/randomKeyNaked";
|
||||
import { Socket } from "socket.io";
|
||||
import * as privacyKit from "privacy-kit";
|
||||
|
||||
export function artifactUpdateHandler(userId: string, socket: Socket, eventRouter: EventRouter) {
|
||||
export function artifactUpdateHandler(userId: string, socket: Socket) {
|
||||
// Read artifact with full body
|
||||
socket.on('artifact-read', async (data: {
|
||||
artifactId: string;
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { machineAliveEventsCounter, websocketEventsCounter } from "@/app/monitoring/metrics2";
|
||||
import { activityCache } from "@/app/presence/sessionCache";
|
||||
import { buildMachineActivityEphemeral, buildUpdateMachineUpdate, EventRouter } from "@/app/events/eventRouter";
|
||||
import { buildMachineActivityEphemeral, buildUpdateMachineUpdate, eventRouter } from "@/app/events/eventRouter";
|
||||
import { log } from "@/utils/log";
|
||||
import { db } from "@/storage/db";
|
||||
import { Socket } from "socket.io";
|
||||
import { allocateUserSeq } from "@/storage/seq";
|
||||
import { randomKeyNaked } from "@/utils/randomKeyNaked";
|
||||
|
||||
export function machineUpdateHandler(userId: string, socket: Socket, eventRouter: EventRouter) {
|
||||
export function machineUpdateHandler(userId: string, socket: Socket) {
|
||||
socket.on('machine-alive', async (data: {
|
||||
machineId: string;
|
||||
time: number;
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { EventRouter } from "@/app/events/eventRouter";
|
||||
import { eventRouter } from "@/app/events/eventRouter";
|
||||
import { log } from "@/utils/log";
|
||||
import { Socket } from "socket.io";
|
||||
|
||||
export function rpcHandler(userId: string, socket: Socket, eventRouter: EventRouter, rpcListeners: Map<string, Socket>) {
|
||||
export function rpcHandler(userId: string, socket: Socket, rpcListeners: Map<string, Socket>) {
|
||||
|
||||
// RPC register - Register this socket as a listener for an RPC method
|
||||
socket.on('rpc-register', async (data: any) => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { sessionAliveEventsCounter, websocketEventsCounter } from "@/app/monitoring/metrics2";
|
||||
import { activityCache } from "@/app/presence/sessionCache";
|
||||
import { buildNewMessageUpdate, buildSessionActivityEphemeral, buildUpdateSessionUpdate, ClientConnection, EventRouter } from "@/app/events/eventRouter";
|
||||
import { buildNewMessageUpdate, buildSessionActivityEphemeral, buildUpdateSessionUpdate, ClientConnection, eventRouter } from "@/app/events/eventRouter";
|
||||
import { db } from "@/storage/db";
|
||||
import { allocateSessionSeq, allocateUserSeq } from "@/storage/seq";
|
||||
import { AsyncLock } from "@/utils/lock";
|
||||
@ -8,7 +8,7 @@ import { log } from "@/utils/log";
|
||||
import { randomKeyNaked } from "@/utils/randomKeyNaked";
|
||||
import { Socket } from "socket.io";
|
||||
|
||||
export function sessionUpdateHandler(userId: string, socket: Socket, connection: ClientConnection, eventRouter: EventRouter) {
|
||||
export function sessionUpdateHandler(userId: string, socket: Socket, connection: ClientConnection) {
|
||||
socket.on('update-metadata', async (data: any, callback: (response: any) => void) => {
|
||||
try {
|
||||
const { sid, metadata, expectedVersion } = data;
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { Socket } from "socket.io";
|
||||
import { AsyncLock } from "@/utils/lock";
|
||||
import { db } from "@/storage/db";
|
||||
import { buildUsageEphemeral, EventRouter } from "@/app/events/eventRouter";
|
||||
import { buildUsageEphemeral, eventRouter } from "@/app/events/eventRouter";
|
||||
import { log } from "@/utils/log";
|
||||
|
||||
export function usageHandler(userId: string, socket: Socket, eventRouter: EventRouter) {
|
||||
export function usageHandler(userId: string, socket: Socket) {
|
||||
const receiveUsageLock = new AsyncLock();
|
||||
socket.on('usage-report', async (data: any, callback?: (response: any) => void) => {
|
||||
await receiveUsageLock.inLock(async () => {
|
||||
|
@ -183,7 +183,7 @@ export interface EphemeralPayload {
|
||||
|
||||
// === EVENT ROUTER CLASS ===
|
||||
|
||||
export class EventRouter {
|
||||
class EventRouter {
|
||||
private userConnections = new Map<string, Set<ClientConnection>>();
|
||||
|
||||
// === CONNECTION MANAGEMENT ===
|
||||
@ -301,6 +301,8 @@ export class EventRouter {
|
||||
}
|
||||
}
|
||||
|
||||
export const eventRouter = new EventRouter();
|
||||
|
||||
// === EVENT BUILDER FUNCTIONS ===
|
||||
|
||||
export function buildNewSessionUpdate(session: {
|
||||
|
108
sources/app/github/githubConnect.ts
Normal file
108
sources/app/github/githubConnect.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { db } from "@/storage/db";
|
||||
import { Context } from "@/context";
|
||||
import { encryptString } from "@/modules/encrypt";
|
||||
import { uploadImage } from "@/storage/uploadImage";
|
||||
import { separateName } from "@/utils/separateName";
|
||||
import { GitHubProfile } from "@/app/api/types";
|
||||
import { allocateUserSeq } from "@/storage/seq";
|
||||
import { buildUpdateAccountUpdate, eventRouter } from "@/app/events/eventRouter";
|
||||
import { randomKeyNaked } from "@/utils/randomKeyNaked";
|
||||
import { githubDisconnect } from "./githubDisconnect";
|
||||
|
||||
/**
|
||||
* Connects a GitHub account to a user profile.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Check if already connected to same account - early exit if yes
|
||||
* 2. If GitHub account is connected to another user - disconnect it first
|
||||
* 3. Upload avatar to S3 (non-transactional operation)
|
||||
* 4. In transaction: persist GitHub account and link to user with GitHub username
|
||||
* 5. Send socket update after transaction completes
|
||||
*
|
||||
* @param ctx - Request context containing user ID
|
||||
* @param githubProfile - GitHub profile data from OAuth
|
||||
* @param accessToken - GitHub access token for API access
|
||||
*/
|
||||
export async function githubConnect(
|
||||
ctx: Context,
|
||||
githubProfile: GitHubProfile,
|
||||
accessToken: string
|
||||
): Promise<void> {
|
||||
const userId = ctx.uid;
|
||||
const githubUserId = githubProfile.id.toString();
|
||||
|
||||
// Step 1: Check if user is already connected to this exact GitHub account
|
||||
const currentUser = await db.account.findFirstOrThrow({
|
||||
where: { id: userId },
|
||||
select: { githubUserId: true, username: true }
|
||||
});
|
||||
if (currentUser.githubUserId === githubUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Check if GitHub account is connected to another user
|
||||
const existingConnection = await db.account.findFirst({
|
||||
where: {
|
||||
githubUserId: githubUserId,
|
||||
NOT: { id: userId }
|
||||
}
|
||||
});
|
||||
if (existingConnection) {
|
||||
const disconnectCtx: Context = Context.create(existingConnection.id);
|
||||
await githubDisconnect(disconnectCtx);
|
||||
}
|
||||
|
||||
// Step 3: Upload avatar to S3 (outside transaction for performance)
|
||||
const imageResponse = await fetch(githubProfile.avatar_url);
|
||||
const imageBuffer = await imageResponse.arrayBuffer();
|
||||
const avatar = await uploadImage(userId, 'avatars', 'github', githubProfile.avatar_url, Buffer.from(imageBuffer));
|
||||
|
||||
// Extract name from GitHub profile
|
||||
const name = separateName(githubProfile.name);
|
||||
|
||||
// Step 4: Start transaction for atomic database operations
|
||||
await db.$transaction(async (tx) => {
|
||||
|
||||
// Upsert GitHub user record with encrypted token
|
||||
await tx.githubUser.upsert({
|
||||
where: { id: githubUserId },
|
||||
update: {
|
||||
profile: githubProfile,
|
||||
token: encryptString(['user', userId, 'github', 'token'], accessToken)
|
||||
},
|
||||
create: {
|
||||
id: githubUserId,
|
||||
profile: githubProfile,
|
||||
token: encryptString(['user', userId, 'github', 'token'], accessToken)
|
||||
}
|
||||
});
|
||||
|
||||
// Link GitHub account to user
|
||||
await tx.account.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
githubUserId: githubUserId,
|
||||
username: githubProfile.login,
|
||||
firstName: name.firstName,
|
||||
lastName: name.lastName,
|
||||
avatar: avatar
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Step 5: Send update via socket (after transaction completes)
|
||||
const updSeq = await allocateUserSeq(userId);
|
||||
const updatePayload = buildUpdateAccountUpdate(userId, {
|
||||
github: githubProfile,
|
||||
username: githubProfile.login,
|
||||
firstName: name.firstName,
|
||||
lastName: name.lastName,
|
||||
avatar: avatar
|
||||
}, updSeq, randomKeyNaked(12));
|
||||
|
||||
eventRouter.emitUpdate({
|
||||
userId,
|
||||
payload: updatePayload,
|
||||
recipientFilter: { type: 'all-user-authenticated-connections' }
|
||||
});
|
||||
}
|
70
sources/app/github/githubDisconnect.ts
Normal file
70
sources/app/github/githubDisconnect.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { db } from "@/storage/db";
|
||||
import { Context } from "@/context";
|
||||
import { log } from "@/utils/log";
|
||||
import { allocateUserSeq } from "@/storage/seq";
|
||||
import { buildUpdateAccountUpdate, eventRouter } from "@/app/events/eventRouter";
|
||||
import { randomKeyNaked } from "@/utils/randomKeyNaked";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Disconnects a GitHub account from a user profile.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Check if user has GitHub connected - early exit if not
|
||||
* 2. In transaction: clear GitHub link, username, avatar from account and delete GitHub user record
|
||||
* 3. Send socket update after transaction completes
|
||||
*
|
||||
* @param ctx - Request context containing user ID
|
||||
*/
|
||||
export async function githubDisconnect(ctx: Context): Promise<void> {
|
||||
const userId = ctx.uid;
|
||||
|
||||
// Step 1: Check if user has GitHub connection
|
||||
const user = await db.account.findUnique({
|
||||
where: { id: userId },
|
||||
select: { githubUserId: true }
|
||||
});
|
||||
|
||||
// Early exit if no GitHub connection
|
||||
if (!user?.githubUserId) {
|
||||
log({ module: 'github-disconnect' }, `User ${userId} has no GitHub account connected`);
|
||||
return;
|
||||
}
|
||||
|
||||
const githubUserId = user.githubUserId;
|
||||
log({ module: 'github-disconnect' }, `Disconnecting GitHub account ${githubUserId} from user ${userId}`);
|
||||
|
||||
// Step 2: Transaction for atomic database operations
|
||||
await db.$transaction(async (tx) => {
|
||||
// Clear GitHub connection, username, and avatar from account
|
||||
await tx.account.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
githubUserId: null,
|
||||
username: null,
|
||||
avatar: Prisma.JsonNull
|
||||
}
|
||||
});
|
||||
|
||||
// Delete GitHub user record (includes token)
|
||||
await tx.githubUser.delete({
|
||||
where: { id: githubUserId }
|
||||
});
|
||||
});
|
||||
|
||||
// Step 3: Send update via socket (after transaction completes)
|
||||
const updSeq = await allocateUserSeq(userId);
|
||||
const updatePayload = buildUpdateAccountUpdate(userId, {
|
||||
github: null,
|
||||
username: null,
|
||||
avatar: null
|
||||
}, updSeq, randomKeyNaked(12));
|
||||
|
||||
eventRouter.emitUpdate({
|
||||
userId,
|
||||
payload: updatePayload,
|
||||
recipientFilter: { type: 'all-user-authenticated-connections' }
|
||||
});
|
||||
|
||||
log({ module: 'github-disconnect' }, `GitHub account ${githubUserId} disconnected successfully from user ${userId}`);
|
||||
}
|
@ -2,9 +2,9 @@ import { db } from "@/storage/db";
|
||||
import { delay } from "@/utils/delay";
|
||||
import { forever } from "@/utils/forever";
|
||||
import { shutdownSignal } from "@/utils/shutdown";
|
||||
import { buildMachineActivityEphemeral, buildSessionActivityEphemeral, EventRouter } from "@/app/events/eventRouter";
|
||||
import { buildMachineActivityEphemeral, buildSessionActivityEphemeral, eventRouter } from "@/app/events/eventRouter";
|
||||
|
||||
export function startTimeout(eventRouter: EventRouter) {
|
||||
export function startTimeout() {
|
||||
forever('session-timeout', async () => {
|
||||
while (true) {
|
||||
// Find timed out sessions
|
||||
|
@ -23,7 +23,7 @@ export async function friendAdd(ctx: Context, uid: string): Promise<UserProfile
|
||||
where: { id: uid },
|
||||
include: { githubUser: true }
|
||||
});
|
||||
if (!currentUser || !currentUser.githubUser || !targetUser || !targetUser.githubUser) {
|
||||
if (!currentUser || !targetUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -21,12 +21,10 @@ export async function friendList(ctx: Context): Promise<UserProfile[]> {
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out users without GitHub profiles and build UserProfile objects
|
||||
// Build UserProfile objects
|
||||
const profiles: UserProfile[] = [];
|
||||
for (const relationship of relationships) {
|
||||
if (relationship.toUser.githubUser) {
|
||||
profiles.push(buildUserProfile(relationship.toUser, relationship.status));
|
||||
}
|
||||
profiles.push(buildUserProfile(relationship.toUser, relationship.status));
|
||||
}
|
||||
|
||||
return profiles;
|
||||
|
@ -17,7 +17,7 @@ export async function friendRemove(ctx: Context, uid: string): Promise<UserProfi
|
||||
where: { id: uid },
|
||||
include: { githubUser: true }
|
||||
});
|
||||
if (!currentUser || !currentUser.githubUser || !targetUser || !targetUser.githubUser) {
|
||||
if (!currentUser || !targetUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { getPublicUrl, ImageRef } from "@/storage/files";
|
||||
import { Prisma, RelationshipStatus } from "@prisma/client";
|
||||
import { RelationshipStatus } from "@prisma/client";
|
||||
import { GitHubProfile } from "../api/types";
|
||||
|
||||
export type UserProfile = {
|
||||
@ -17,19 +17,12 @@ export type UserProfile = {
|
||||
status: RelationshipStatus;
|
||||
}
|
||||
|
||||
// Avatar type definition matching the database JSON structure
|
||||
type AvatarData = {
|
||||
path: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
thumbhash?: string;
|
||||
};
|
||||
|
||||
export function buildUserProfile(
|
||||
account: {
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
username: string | null;
|
||||
avatar: ImageRef | null;
|
||||
githubUser: { profile: GitHubProfile } | null;
|
||||
},
|
||||
@ -55,7 +48,7 @@ export function buildUserProfile(
|
||||
firstName: account.firstName || '',
|
||||
lastName: account.lastName,
|
||||
avatar,
|
||||
username: githubProfile?.login || '',
|
||||
username: account.username || githubProfile?.login || '',
|
||||
status
|
||||
};
|
||||
}
|
34
sources/app/social/usernameUpdate.ts
Normal file
34
sources/app/social/usernameUpdate.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { db } from "@/storage/db";
|
||||
import { Context } from "@/context";
|
||||
import { allocateUserSeq } from "@/storage/seq";
|
||||
import { buildUpdateAccountUpdate, eventRouter } from "@/app/events/eventRouter";
|
||||
import { randomKeyNaked } from "@/utils/randomKeyNaked";
|
||||
|
||||
export async function usernameUpdate(ctx: Context, username: string): Promise<void> {
|
||||
const userId = ctx.uid;
|
||||
|
||||
// Check if username is already taken
|
||||
const existingUser = await db.account.findFirst({
|
||||
where: {
|
||||
username: username,
|
||||
NOT: { id: userId }
|
||||
}
|
||||
});
|
||||
if (existingUser) { // Should never happen
|
||||
throw new Error('Username is already taken');
|
||||
}
|
||||
|
||||
// Update username
|
||||
await db.account.update({
|
||||
where: { id: userId },
|
||||
data: { username: username }
|
||||
});
|
||||
|
||||
// Send account update to all user connections
|
||||
const updSeq = await allocateUserSeq(userId);
|
||||
const updatePayload = buildUpdateAccountUpdate(userId, { username: username }, updSeq, randomKeyNaked(12));
|
||||
eventRouter.emitUpdate({
|
||||
userId, payload: updatePayload,
|
||||
recipientFilter: { type: 'all-user-authenticated-connections' }
|
||||
});
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
|
||||
export class Context {
|
||||
|
||||
static create(uid: string) {
|
||||
@ -9,4 +11,6 @@ export class Context {
|
||||
private constructor(uid: string) {
|
||||
this.uid = uid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type Tx = Prisma.TransactionClient | PrismaClient;
|
@ -11,7 +11,6 @@ import { startDatabaseMetricsUpdater } from "@/app/monitoring/metrics2";
|
||||
import { initEncrypt } from "./modules/encrypt";
|
||||
import { initGithub } from "./modules/github";
|
||||
import { loadFiles } from "./storage/files";
|
||||
import { EventRouter } from "./app/events/eventRouter";
|
||||
|
||||
async function main() {
|
||||
|
||||
@ -26,7 +25,6 @@ async function main() {
|
||||
await redis.ping();
|
||||
|
||||
// Initialize auth module
|
||||
const eventRouter = new EventRouter();
|
||||
await initEncrypt();
|
||||
await initGithub();
|
||||
await loadFiles();
|
||||
@ -36,10 +34,10 @@ async function main() {
|
||||
// Start
|
||||
//
|
||||
|
||||
await startApi(eventRouter);
|
||||
await startApi();
|
||||
await startMetricsServer();
|
||||
startDatabaseMetricsUpdater();
|
||||
startTimeout(eventRouter);
|
||||
startTimeout();
|
||||
|
||||
//
|
||||
// Ready
|
||||
|
@ -4,6 +4,7 @@ import { ImageRef } from "./storage/files";
|
||||
export type AccountProfile = {
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
username: string | null;
|
||||
avatar: ImageRef | null;
|
||||
github: GitHubProfile | null;
|
||||
settings: {
|
||||
|
Loading…
Reference in New Issue
Block a user