From 7ce1c2a8bc1d1133e14e4c65d3e3ee6695b8ca4a Mon Sep 17 00:00:00 2001 From: omigamedev Date: Mon, 2 Mar 2026 12:32:39 +0100 Subject: [PATCH] Portal backend: Facebook OAuth, social features, portal comments - Facebook Login OAuth (meta-web auth service + routes) - Account linking (merge Quest metaId + Facebook facebookId) - User profile updates (bio, isPublic, displayName) - Social endpoints: follow/unfollow, feed (trending/following/recent), likes - Portal comments via WebSocket (subscribe_portal, send_portal_comment) - Prisma migration: Follow, Like models, facebookId/bio/isPublic on User - Provider OAuth source=web redirect support for portal callbacks - Docker compose portal service, CORS multi-origin support --- docker-compose.yml | 20 +- .../migration.sql | 57 ++++++ prisma/schema.prisma | 34 +++- src/app.ts | 17 +- src/config.ts | 7 + src/routes/auth/link.ts | 104 ++++++++++ src/routes/auth/meta-web.ts | 128 +++++++++++++ src/routes/auth/session.ts | 45 ++++- src/routes/chat/websocket.ts | 20 +- src/routes/providers/twitch.ts | 28 ++- src/routes/providers/youtube.ts | 29 ++- src/routes/social/feed.ts | 98 ++++++++++ src/routes/social/following.ts | 180 ++++++++++++++++++ src/routes/social/likes.ts | 75 ++++++++ src/services/chat-manager.service.ts | 120 ++++++++++++ src/services/meta-web-auth.service.ts | 66 +++++++ src/types/api.ts | 58 ++++++ 17 files changed, 1060 insertions(+), 26 deletions(-) create mode 100644 prisma/migrations/20260302101146_add_social_features/migration.sql create mode 100644 src/routes/auth/link.ts create mode 100644 src/routes/auth/meta-web.ts create mode 100644 src/routes/social/feed.ts create mode 100644 src/routes/social/following.ts create mode 100644 src/routes/social/likes.ts create mode 100644 src/services/meta-web-auth.service.ts diff --git a/docker-compose.yml b/docker-compose.yml index f9bcfd8..b6b79a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,26 @@ services: environment: - DATABASE_URL=file:/app/data/lck.db healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"] + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] interval: 30s timeout: 5s retries: 3 start_period: 10s + + portal: + build: + context: ../lck-control-portal + dockerfile: Dockerfile + container_name: lck-control-portal + restart: unless-stopped + ports: + - "3200:3000" + environment: + - NEXT_PUBLIC_API_URL=https://lck.omigame.dev + - HOSTNAME=0.0.0.0 + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s diff --git a/prisma/migrations/20260302101146_add_social_features/migration.sql b/prisma/migrations/20260302101146_add_social_features/migration.sql new file mode 100644 index 0000000..72e4b5b --- /dev/null +++ b/prisma/migrations/20260302101146_add_social_features/migration.sql @@ -0,0 +1,57 @@ +-- CreateTable +CREATE TABLE "Follow" ( + "id" TEXT NOT NULL PRIMARY KEY, + "followerId" TEXT NOT NULL, + "followingId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Follow_followerId_fkey" FOREIGN KEY ("followerId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "Follow_followingId_fkey" FOREIGN KEY ("followingId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Like" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "planId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Like_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "Like_planId_fkey" FOREIGN KEY ("planId") REFERENCES "StreamPlan" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "metaId" TEXT, + "facebookId" TEXT, + "displayName" TEXT NOT NULL, + "email" TEXT, + "avatarUrl" TEXT, + "bio" TEXT NOT NULL DEFAULT '', + "isPublic" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_User" ("avatarUrl", "createdAt", "displayName", "email", "id", "metaId", "updatedAt") SELECT "avatarUrl", "createdAt", "displayName", "email", "id", "metaId", "updatedAt" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_metaId_key" ON "User"("metaId"); +CREATE UNIQUE INDEX "User_facebookId_key" ON "User"("facebookId"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE INDEX "Follow_followerId_idx" ON "Follow"("followerId"); + +-- CreateIndex +CREATE INDEX "Follow_followingId_idx" ON "Follow"("followingId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Follow_followerId_followingId_key" ON "Follow"("followerId", "followingId"); + +-- CreateIndex +CREATE INDEX "Like_planId_idx" ON "Like"("planId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Like_userId_planId_key" ON "Like"("userId", "planId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 397c3a8..fcd04cd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,16 +9,22 @@ datasource db { model User { id String @id @default(uuid()) - metaId String @unique + metaId String? @unique + facebookId String? @unique displayName String email String? avatarUrl String? + bio String @default("") + isPublic Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt linkedAccounts LinkedAccount[] streamPlans StreamPlan[] sessions Session[] + followers Follow[] @relation("following") + following Follow[] @relation("follower") + likes Like[] } model Session { @@ -64,6 +70,7 @@ model StreamPlan { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt destinations StreamDestination[] + likes Like[] @@index([userId]) } @@ -86,3 +93,28 @@ model StreamDestination { @@index([planId]) } + +model Follow { + id String @id @default(uuid()) + followerId String + follower User @relation("follower", fields: [followerId], references: [id], onDelete: Cascade) + followingId String + following User @relation("following", fields: [followingId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@unique([followerId, followingId]) + @@index([followerId]) + @@index([followingId]) +} + +model Like { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + planId String + plan StreamPlan @relation(fields: [planId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@unique([userId, planId]) + @@index([planId]) +} diff --git a/src/app.ts b/src/app.ts index fe56e37..1d713a2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,12 +7,17 @@ import errorHandlerPlugin from './plugins/error-handler.js'; import authPlugin from './plugins/auth.js'; import healthRoutes from './routes/health.js'; import metaAuthRoutes from './routes/auth/meta.js'; +import metaWebRoutes from './routes/auth/meta-web.js'; import sessionRoutes from './routes/auth/session.js'; +import linkRoutes from './routes/auth/link.js'; import accountRoutes from './routes/providers/accounts.js'; import youtubeRoutes from './routes/providers/youtube.js'; import twitchRoutes from './routes/providers/twitch.js'; import planRoutes from './routes/streams/plans.js'; import lifecycleRoutes from './routes/streams/lifecycle.js'; +import followingRoutes from './routes/social/following.js'; +import feedRoutes from './routes/social/feed.js'; +import likesRoutes from './routes/social/likes.js'; import { createChatRoutes } from './routes/chat/websocket.js'; import { ChatManager } from './services/chat-manager.service.js'; import { config } from './config.js'; @@ -24,8 +29,11 @@ export async function buildApp() { }, }); - // Plugins - await app.register(cors, { origin: config.corsOrigin }); + // Plugins — support comma-separated CORS origins + const corsOrigins = config.corsOrigin === '*' + ? '*' + : config.corsOrigin.split(',').map(s => s.trim()); + await app.register(cors, { origin: corsOrigins }); await app.register(rateLimit, { global: true, max: 100, @@ -42,12 +50,17 @@ export async function buildApp() { // Routes await app.register(healthRoutes); await app.register(metaAuthRoutes); + await app.register(metaWebRoutes); await app.register(sessionRoutes); + await app.register(linkRoutes); await app.register(accountRoutes); await app.register(youtubeRoutes); await app.register(twitchRoutes); await app.register(planRoutes); await app.register(lifecycleRoutes); + await app.register(followingRoutes); + await app.register(feedRoutes); + await app.register(likesRoutes); await app.register(createChatRoutes(chatManager)); return app; diff --git a/src/config.ts b/src/config.ts index 099c503..1abcc7c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -38,6 +38,13 @@ export const config = { redirectUri: required('TWITCH_REDIRECT_URI'), }, + metaWeb: { + clientId: optional('META_WEB_CLIENT_ID', ''), + clientSecret: optional('META_WEB_CLIENT_SECRET', ''), + redirectUri: optional('META_WEB_REDIRECT_URI', ''), + }, + + portalUrl: optional('PORTAL_URL', 'https://portal.omigame.dev'), appScheme: optional('APP_SCHEME', 'com.omixlab.lckcontrol'), corsOrigin: optional('CORS_ORIGIN', '*'), } as const; diff --git a/src/routes/auth/link.ts b/src/routes/auth/link.ts new file mode 100644 index 0000000..653d630 --- /dev/null +++ b/src/routes/auth/link.ts @@ -0,0 +1,104 @@ +import { FastifyPluginAsync } from 'fastify'; +import { requireAuth } from '../../middleware/require-auth.js'; +import { verifyUserProof } from '../../services/meta-auth.service.js'; +import { exchangeFacebookCode, fetchFacebookProfile } from '../../services/meta-web-auth.service.js'; +import { AppError } from '../../plugins/error-handler.js'; +import type { LinkQuestBody, LinkFacebookBody } from '../../types/api.js'; + +const linkRoutes: FastifyPluginAsync = async (fastify) => { + // POST /auth/link-quest — portal user links their Quest account + fastify.post<{ Body: LinkQuestBody }>('/auth/link-quest', { + preHandler: [requireAuth], + schema: { + body: { + type: 'object', + required: ['metaId'], + properties: { + metaId: { type: 'string', minLength: 1 }, + nonce: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + }, async (request, reply) => { + const { metaId, nonce } = request.body; + + // Validate nonce if provided + if (nonce) { + const isValid = await verifyUserProof(metaId, nonce); + if (!isValid) { + throw new AppError(401, 'Invalid user proof'); + } + } + + // Check if metaId is already linked to another user + const existingUser = await fastify.prisma.user.findUnique({ where: { metaId } }); + if (existingUser && existingUser.id !== request.userId) { + // Merge: move all data from existing user to current user + await fastify.prisma.$transaction([ + fastify.prisma.linkedAccount.updateMany({ where: { userId: existingUser.id }, data: { userId: request.userId } }), + fastify.prisma.streamPlan.updateMany({ where: { userId: existingUser.id }, data: { userId: request.userId } }), + fastify.prisma.session.deleteMany({ where: { userId: existingUser.id } }), + fastify.prisma.follow.updateMany({ where: { followerId: existingUser.id }, data: { followerId: request.userId } }), + fastify.prisma.follow.updateMany({ where: { followingId: existingUser.id }, data: { followingId: request.userId } }), + fastify.prisma.like.updateMany({ where: { userId: existingUser.id }, data: { userId: request.userId } }), + fastify.prisma.user.delete({ where: { id: existingUser.id } }), + fastify.prisma.user.update({ where: { id: request.userId }, data: { metaId } }), + ]); + } else { + // Simply set metaId on current user + await fastify.prisma.user.update({ + where: { id: request.userId }, + data: { metaId }, + }); + } + + reply.status(200).send({ success: true }); + }); + + // POST /auth/link-facebook — Quest user links their Facebook account + fastify.post<{ Body: LinkFacebookBody }>('/auth/link-facebook', { + preHandler: [requireAuth], + schema: { + body: { + type: 'object', + required: ['code', 'state'], + properties: { + code: { type: 'string', minLength: 1 }, + state: { type: 'string', minLength: 1 }, + }, + additionalProperties: false, + }, + }, + }, async (request, reply) => { + const { code } = request.body; + + const { accessToken: fbToken } = await exchangeFacebookCode(code); + const profile = await fetchFacebookProfile(fbToken); + + // Check if facebookId is already linked to another user + const existingUser = await fastify.prisma.user.findUnique({ where: { facebookId: profile.facebookId } }); + if (existingUser && existingUser.id !== request.userId) { + // Merge: move all data from existing user to current user + await fastify.prisma.$transaction([ + fastify.prisma.linkedAccount.updateMany({ where: { userId: existingUser.id }, data: { userId: request.userId } }), + fastify.prisma.streamPlan.updateMany({ where: { userId: existingUser.id }, data: { userId: request.userId } }), + fastify.prisma.session.deleteMany({ where: { userId: existingUser.id } }), + fastify.prisma.follow.updateMany({ where: { followerId: existingUser.id }, data: { followerId: request.userId } }), + fastify.prisma.follow.updateMany({ where: { followingId: existingUser.id }, data: { followingId: request.userId } }), + fastify.prisma.like.updateMany({ where: { userId: existingUser.id }, data: { userId: request.userId } }), + fastify.prisma.user.delete({ where: { id: existingUser.id } }), + fastify.prisma.user.update({ where: { id: request.userId }, data: { facebookId: profile.facebookId } }), + ]); + } else { + await fastify.prisma.user.update({ + where: { id: request.userId }, + data: { facebookId: profile.facebookId }, + }); + } + + reply.status(200).send({ success: true }); + }); +}; + +export default linkRoutes; diff --git a/src/routes/auth/meta-web.ts b/src/routes/auth/meta-web.ts new file mode 100644 index 0000000..8ba09be --- /dev/null +++ b/src/routes/auth/meta-web.ts @@ -0,0 +1,128 @@ +import { FastifyPluginAsync } from 'fastify'; +import { randomUUID } from 'node:crypto'; +import { signAccessToken } from '../../plugins/auth.js'; +import { hashToken } from '../../services/crypto.service.js'; +import { exchangeFacebookCode, fetchFacebookProfile, getFacebookOAuthUrl } from '../../services/meta-web-auth.service.js'; +import { config } from '../../config.js'; +import { AppError } from '../../plugins/error-handler.js'; +import type { AuthTokensResponse, MetaWebCallbackBody } from '../../types/api.js'; + +// In-memory CSRF state store +const pendingStates = new Map(); + +setInterval(() => { + const now = Date.now(); + for (const [key, val] of pendingStates) { + if (val.expiresAt < now) pendingStates.delete(key); + } +}, 5 * 60 * 1000); + +const metaWebRoutes: FastifyPluginAsync = async (fastify) => { + // GET /auth/meta/web/auth-url — returns Facebook OAuth dialog URL + fastify.get('/auth/meta/web/auth-url', { + config: { + rateLimit: { max: 10, timeWindow: '1 minute' }, + }, + }, async () => { + const state = randomUUID(); + pendingStates.set(state, { + expiresAt: Date.now() + 10 * 60 * 1000, + }); + + return { + url: getFacebookOAuthUrl(state), + state, + }; + }); + + // GET /auth/meta/web/callback-redirect — Facebook redirects here → 302 to portal + fastify.get<{ Querystring: { code?: string; state?: string; error?: string } }>( + '/auth/meta/web/callback-redirect', + async (request, reply) => { + const { code, state, error } = request.query; + if (error || !code || !state) { + reply.status(302).redirect( + `${config.portalUrl}/auth/callback?error=${error || 'missing_params'}`, + ); + return; + } + + reply.status(302).redirect( + `${config.portalUrl}/auth/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`, + ); + }, + ); + + // POST /auth/meta/web/callback — exchanges Facebook code for JWT + fastify.post<{ Body: MetaWebCallbackBody }>('/auth/meta/web/callback', { + config: { + rateLimit: { max: 10, timeWindow: '1 minute' }, + }, + schema: { + body: { + type: 'object', + required: ['code', 'state'], + properties: { + code: { type: 'string', minLength: 1 }, + state: { type: 'string', minLength: 1 }, + }, + additionalProperties: false, + }, + }, + }, async (request, reply) => { + const { code, state } = request.body; + + // Validate CSRF state + const pending = pendingStates.get(state); + if (!pending || pending.expiresAt < Date.now()) { + pendingStates.delete(state); + throw new AppError(400, 'Invalid or expired state parameter'); + } + pendingStates.delete(state); + + // Exchange code for Facebook access token + const { accessToken: fbToken } = await exchangeFacebookCode(code); + const profile = await fetchFacebookProfile(fbToken); + + // Upsert user by facebookId + const user = await fastify.prisma.user.upsert({ + where: { facebookId: profile.facebookId }, + update: { + displayName: profile.displayName, + email: profile.email, + avatarUrl: profile.avatarUrl, + }, + create: { + facebookId: profile.facebookId, + displayName: profile.displayName, + email: profile.email, + avatarUrl: profile.avatarUrl, + }, + }); + + // Create session + const refreshToken = randomUUID(); + const expiresAt = new Date(Date.now() + config.jwt.refreshTtl * 1000); + + await fastify.prisma.session.create({ + data: { + userId: user.id, + refreshToken: hashToken(refreshToken), + expiresAt, + deviceInfo: 'web-portal', + }, + }); + + const accessToken = await signAccessToken(user.id); + + const response: AuthTokensResponse = { + accessToken, + refreshToken, + expiresIn: config.jwt.accessTtl, + }; + + reply.status(200).send(response); + }); +}; + +export default metaWebRoutes; diff --git a/src/routes/auth/session.ts b/src/routes/auth/session.ts index 9ce18b4..e0a7abb 100644 --- a/src/routes/auth/session.ts +++ b/src/routes/auth/session.ts @@ -5,7 +5,7 @@ import { hashToken } from '../../services/crypto.service.js'; import { requireAuth } from '../../middleware/require-auth.js'; import { config } from '../../config.js'; import { AppError } from '../../plugins/error-handler.js'; -import type { RefreshBody, AuthTokensResponse, UserProfileResponse } from '../../types/api.js'; +import type { RefreshBody, AuthTokensResponse, UserProfileResponse, UpdateProfileBody } from '../../types/api.js'; const sessionRoutes: FastifyPluginAsync = async (fastify) => { // POST /auth/refresh — rotate refresh token, issue new JWT @@ -83,6 +83,49 @@ const sessionRoutes: FastifyPluginAsync = async (fastify) => { displayName: user.displayName, email: user.email, avatarUrl: user.avatarUrl, + bio: user.bio, + isPublic: user.isPublic, + hasQuestLink: !!user.metaId, + hasFacebookLink: !!user.facebookId, + }; + + reply.status(200).send(response); + }); + + // PATCH /auth/me — update profile + fastify.patch<{ Body: UpdateProfileBody }>('/auth/me', { + preHandler: [requireAuth], + schema: { + body: { + type: 'object', + properties: { + displayName: { type: 'string', minLength: 1, maxLength: 100 }, + bio: { type: 'string', maxLength: 500 }, + isPublic: { type: 'boolean' }, + }, + additionalProperties: false, + }, + }, + }, async (request, reply) => { + const data: any = {}; + if (request.body.displayName !== undefined) data.displayName = request.body.displayName; + if (request.body.bio !== undefined) data.bio = request.body.bio; + if (request.body.isPublic !== undefined) data.isPublic = request.body.isPublic; + + const user = await fastify.prisma.user.update({ + where: { id: request.userId }, + data, + }); + + const response: UserProfileResponse = { + id: user.id, + displayName: user.displayName, + email: user.email, + avatarUrl: user.avatarUrl, + bio: user.bio, + isPublic: user.isPublic, + hasQuestLink: !!user.metaId, + hasFacebookLink: !!user.facebookId, }; reply.status(200).send(response); diff --git a/src/routes/chat/websocket.ts b/src/routes/chat/websocket.ts index 7ded865..3bacaf6 100644 --- a/src/routes/chat/websocket.ts +++ b/src/routes/chat/websocket.ts @@ -3,7 +3,7 @@ import { verifyAccessToken } from '../../plugins/auth.js'; import { ChatManager } from '../../services/chat-manager.service.js'; interface ChatWsMessage { - type: 'subscribe' | 'unsubscribe' | 'send_message'; + type: 'subscribe' | 'unsubscribe' | 'send_message' | 'subscribe_portal' | 'send_portal_comment' | 'like'; planId?: string; destinationId?: string; text?: string; @@ -63,6 +63,24 @@ export function createChatRoutes(chatManager: ChatManager): FastifyPluginAsync { } break; + case 'subscribe_portal': + if (msg.planId) { + await chatManager.subscribePortalChat(msg.planId, userId, socket); + } + break; + + case 'send_portal_comment': + if (msg.planId && msg.text) { + await chatManager.handlePortalComment(msg.planId, userId, msg.text); + } + break; + + case 'like': + if (msg.planId) { + await chatManager.handleLike(msg.planId, userId); + } + break; + default: socket.send(JSON.stringify({ type: 'error', error: `Unknown message type: ${msg.type}` })); } diff --git a/src/routes/providers/twitch.ts b/src/routes/providers/twitch.ts index cfb9243..2e6fb36 100644 --- a/src/routes/providers/twitch.ts +++ b/src/routes/providers/twitch.ts @@ -11,8 +11,8 @@ import { config } from '../../config.js'; import { AppError } from '../../plugins/error-handler.js'; import type { AuthUrlResponse, ProviderCallbackBody, LinkedAccountResponse } from '../../types/api.js'; -// In-memory CSRF state store (state → { userId, expiresAt }) -const pendingStates = new Map(); +// In-memory CSRF state store (state → { userId, expiresAt, source }) +const pendingStates = new Map(); setInterval(() => { const now = Date.now(); @@ -23,13 +23,15 @@ setInterval(() => { const twitchRoutes: FastifyPluginAsync = async (fastify) => { // GET /providers/twitch/auth-url — get OAuth URL with CSRF state - fastify.get('/providers/twitch/auth-url', { + fastify.get<{ Querystring: { source?: string } }>('/providers/twitch/auth-url', { preHandler: [requireAuth], }, async (request) => { + const source = request.query.source; const state = randomUUID(); pendingStates.set(state, { userId: request.userId, expiresAt: Date.now() + 10 * 60 * 1000, + source, }); const response: AuthUrlResponse = { @@ -39,21 +41,27 @@ const twitchRoutes: FastifyPluginAsync = async (fastify) => { return response; }); - // GET /providers/twitch/callback-redirect — Twitch redirects here → 302 to app deep link + // GET /providers/twitch/callback-redirect — Twitch redirects here → 302 to app deep link or portal fastify.get<{ Querystring: { code?: string; state?: string; error?: string } }>( '/providers/twitch/callback-redirect', async (request, reply) => { const { code, state, error } = request.query; + + const pending = state ? pendingStates.get(state) : undefined; + const isWeb = pending?.source === 'web'; + if (error || !code || !state) { - reply.status(302).redirect( - `${config.appScheme}://twitch/callback?error=${error || 'missing_params'}`, - ); + const target = isWeb + ? `${config.portalUrl}/accounts/callback/twitch?error=${error || 'missing_params'}` + : `${config.appScheme}://twitch/callback?error=${error || 'missing_params'}`; + reply.status(302).redirect(target); return; } - reply.status(302).redirect( - `${config.appScheme}://twitch/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`, - ); + const target = isWeb + ? `${config.portalUrl}/accounts/callback/twitch?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` + : `${config.appScheme}://twitch/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`; + reply.status(302).redirect(target); }, ); diff --git a/src/routes/providers/youtube.ts b/src/routes/providers/youtube.ts index 7cd0ae4..0caaef9 100644 --- a/src/routes/providers/youtube.ts +++ b/src/routes/providers/youtube.ts @@ -11,8 +11,8 @@ import { config } from '../../config.js'; import { AppError } from '../../plugins/error-handler.js'; import type { AuthUrlResponse, ProviderCallbackBody, LinkedAccountResponse } from '../../types/api.js'; -// In-memory CSRF state store (state → { userId, expiresAt }) -const pendingStates = new Map(); +// In-memory CSRF state store (state → { userId, expiresAt, source }) +const pendingStates = new Map(); // Clean expired states every 5 minutes setInterval(() => { @@ -24,13 +24,15 @@ setInterval(() => { const youtubeRoutes: FastifyPluginAsync = async (fastify) => { // GET /providers/youtube/auth-url — get OAuth URL with CSRF state - fastify.get('/providers/youtube/auth-url', { + fastify.get<{ Querystring: { source?: string } }>('/providers/youtube/auth-url', { preHandler: [requireAuth], }, async (request) => { + const source = request.query.source; const state = randomUUID(); pendingStates.set(state, { userId: request.userId, expiresAt: Date.now() + 10 * 60 * 1000, // 10 min + source, }); const response: AuthUrlResponse = { @@ -40,21 +42,28 @@ const youtubeRoutes: FastifyPluginAsync = async (fastify) => { return response; }); - // GET /providers/youtube/callback-redirect — Google redirects here → 302 to app deep link + // GET /providers/youtube/callback-redirect — Google redirects here → 302 to app deep link or portal fastify.get<{ Querystring: { code?: string; state?: string; error?: string } }>( '/providers/youtube/callback-redirect', async (request, reply) => { const { code, state, error } = request.query; + + // Check source from pending state to decide redirect target + const pending = state ? pendingStates.get(state) : undefined; + const isWeb = pending?.source === 'web'; + if (error || !code || !state) { - reply.status(302).redirect( - `${config.appScheme}://youtube/callback?error=${error || 'missing_params'}`, - ); + const target = isWeb + ? `${config.portalUrl}/accounts/callback/youtube?error=${error || 'missing_params'}` + : `${config.appScheme}://youtube/callback?error=${error || 'missing_params'}`; + reply.status(302).redirect(target); return; } - reply.status(302).redirect( - `${config.appScheme}://youtube/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`, - ); + const target = isWeb + ? `${config.portalUrl}/accounts/callback/youtube?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` + : `${config.appScheme}://youtube/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`; + reply.status(302).redirect(target); }, ); diff --git a/src/routes/social/feed.ts b/src/routes/social/feed.ts new file mode 100644 index 0000000..b3d83aa --- /dev/null +++ b/src/routes/social/feed.ts @@ -0,0 +1,98 @@ +import { FastifyPluginAsync } from 'fastify'; +import { requireAuth } from '../../middleware/require-auth.js'; +import type { FeedResponse, FeedItemResponse } from '../../types/api.js'; + +const feedRoutes: FastifyPluginAsync = async (fastify) => { + // GET /social/feed?filter=trending|following|recent&cursor=&limit=20 + fastify.get<{ Querystring: { filter?: string; cursor?: string; limit?: string } }>('/social/feed', { + preHandler: [requireAuth], + }, async (request) => { + const filter = request.query.filter || 'trending'; + const limit = Math.min(parseInt(request.query.limit || '20', 10), 50); + const cursorId = request.query.cursor; + + // Base condition: only LIVE plans from public users + const baseWhere: any = { + status: 'LIVE', + user: { isPublic: true }, + }; + + // If following filter, restrict to followed users + if (filter === 'following') { + const myFollowing = await fastify.prisma.follow.findMany({ + where: { followerId: request.userId }, + select: { followingId: true }, + }); + const followingIds = myFollowing.map(f => f.followingId); + baseWhere.userId = { in: followingIds }; + } + + let orderBy: any; + if (filter === 'trending') { + orderBy = { likes: { _count: 'desc' as const } }; + } else { + orderBy = { updatedAt: 'desc' as const }; + } + + const plans = await fastify.prisma.streamPlan.findMany({ + where: baseWhere, + include: { + user: { select: { id: true, displayName: true, avatarUrl: true } }, + destinations: true, + _count: { select: { likes: true } }, + }, + orderBy, + take: limit + 1, + ...(cursorId ? { cursor: { id: cursorId }, skip: 1 } : {}), + }); + + const hasMore = plans.length > limit; + const items = plans.slice(0, limit); + + // Check which plans the current user has liked + const planIds = items.map(p => p.id); + const myLikes = await fastify.prisma.like.findMany({ + where: { userId: request.userId, planId: { in: planIds } }, + select: { planId: true }, + }); + const likedSet = new Set(myLikes.map(l => l.planId)); + + const feedItems: FeedItemResponse[] = items.map(plan => ({ + plan: { + id: plan.id, + name: plan.name, + status: plan.status, + executionMode: plan.executionMode, + gameId: plan.gameId, + createdAt: plan.createdAt.toISOString(), + updatedAt: plan.updatedAt.toISOString(), + destinations: plan.destinations.map(d => ({ + id: d.id, + serviceId: d.serviceId, + linkedAccountId: d.linkedAccountId, + title: d.title, + description: d.description, + privacyStatus: d.privacyStatus, + gameId: d.gameId, + tags: d.tags, + rtmpUrl: '', + streamKey: '', + broadcastId: d.broadcastId, + status: d.status, + })), + user: plan.user, + }, + likeCount: plan._count.likes, + commentCount: 0, // portal comments are ephemeral via WebSocket + isLiked: likedSet.has(plan.id), + })); + + const response: FeedResponse = { + items: feedItems, + nextCursor: hasMore ? items[items.length - 1].id : null, + }; + return response; + }); +}; + +export default feedRoutes; diff --git a/src/routes/social/following.ts b/src/routes/social/following.ts new file mode 100644 index 0000000..2140761 --- /dev/null +++ b/src/routes/social/following.ts @@ -0,0 +1,180 @@ +import { FastifyPluginAsync } from 'fastify'; +import { requireAuth } from '../../middleware/require-auth.js'; +import { AppError } from '../../plugins/error-handler.js'; +import type { PublicUserResponse, FollowListResponse } from '../../types/api.js'; + +const followingRoutes: FastifyPluginAsync = async (fastify) => { + // POST /social/follow/:userId — follow a user + fastify.post<{ Params: { userId: string } }>('/social/follow/:userId', { + preHandler: [requireAuth], + }, async (request, reply) => { + const { userId: targetId } = request.params; + + if (targetId === request.userId) { + throw new AppError(400, 'Cannot follow yourself'); + } + + const target = await fastify.prisma.user.findUnique({ where: { id: targetId } }); + if (!target) throw new AppError(404, 'User not found'); + + await fastify.prisma.follow.upsert({ + where: { + followerId_followingId: { + followerId: request.userId, + followingId: targetId, + }, + }, + update: {}, + create: { + followerId: request.userId, + followingId: targetId, + }, + }); + + reply.status(200).send({ success: true }); + }); + + // DELETE /social/follow/:userId — unfollow a user + fastify.delete<{ Params: { userId: string } }>('/social/follow/:userId', { + preHandler: [requireAuth], + }, async (request, reply) => { + const { userId: targetId } = request.params; + + await fastify.prisma.follow.deleteMany({ + where: { + followerId: request.userId, + followingId: targetId, + }, + }); + + reply.status(200).send({ success: true }); + }); + + // GET /social/followers — list my followers + fastify.get<{ Querystring: { cursor?: string; limit?: string } }>('/social/followers', { + preHandler: [requireAuth], + }, async (request) => { + const limit = Math.min(parseInt(request.query.limit || '20', 10), 50); + const cursor = request.query.cursor; + + const follows = await fastify.prisma.follow.findMany({ + where: { followingId: request.userId }, + include: { + follower: { + include: { + _count: { select: { followers: true, following: true } }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + take: limit + 1, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); + + const hasMore = follows.length > limit; + const items = follows.slice(0, limit); + + // Check which ones the current user follows back + const followerIds = items.map(f => f.followerId); + const myFollowing = await fastify.prisma.follow.findMany({ + where: { followerId: request.userId, followingId: { in: followerIds } }, + select: { followingId: true }, + }); + const followingSet = new Set(myFollowing.map(f => f.followingId)); + + const users: PublicUserResponse[] = items.map(f => ({ + id: f.follower.id, + displayName: f.follower.displayName, + avatarUrl: f.follower.avatarUrl, + bio: f.follower.bio, + followerCount: f.follower._count.followers, + followingCount: f.follower._count.following, + isFollowing: followingSet.has(f.followerId), + })); + + const response: FollowListResponse = { + users, + nextCursor: hasMore ? items[items.length - 1].id : null, + }; + return response; + }); + + // GET /social/following — list who I follow + fastify.get<{ Querystring: { cursor?: string; limit?: string } }>('/social/following', { + preHandler: [requireAuth], + }, async (request) => { + const limit = Math.min(parseInt(request.query.limit || '20', 10), 50); + const cursor = request.query.cursor; + + const follows = await fastify.prisma.follow.findMany({ + where: { followerId: request.userId }, + include: { + following: { + include: { + _count: { select: { followers: true, following: true } }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + take: limit + 1, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); + + const hasMore = follows.length > limit; + const items = follows.slice(0, limit); + + const users: PublicUserResponse[] = items.map(f => ({ + id: f.following.id, + displayName: f.following.displayName, + avatarUrl: f.following.avatarUrl, + bio: f.following.bio, + followerCount: f.following._count.followers, + followingCount: f.following._count.following, + isFollowing: true, + })); + + const response: FollowListResponse = { + users, + nextCursor: hasMore ? items[items.length - 1].id : null, + }; + return response; + }); + + // GET /social/users/:userId — public profile + fastify.get<{ Params: { userId: string } }>('/social/users/:userId', { + preHandler: [requireAuth], + }, async (request) => { + const { userId: targetId } = request.params; + + const user = await fastify.prisma.user.findUnique({ + where: { id: targetId }, + include: { + _count: { select: { followers: true, following: true } }, + }, + }); + + if (!user) throw new AppError(404, 'User not found'); + + const isFollowing = await fastify.prisma.follow.findUnique({ + where: { + followerId_followingId: { + followerId: request.userId, + followingId: targetId, + }, + }, + }); + + const response: PublicUserResponse = { + id: user.id, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + bio: user.bio, + followerCount: user._count.followers, + followingCount: user._count.following, + isFollowing: !!isFollowing, + }; + return response; + }); +}; + +export default followingRoutes; diff --git a/src/routes/social/likes.ts b/src/routes/social/likes.ts new file mode 100644 index 0000000..790f046 --- /dev/null +++ b/src/routes/social/likes.ts @@ -0,0 +1,75 @@ +import { FastifyPluginAsync } from 'fastify'; +import { requireAuth } from '../../middleware/require-auth.js'; +import { AppError } from '../../plugins/error-handler.js'; +import type { LikeStatusResponse } from '../../types/api.js'; + +const likesRoutes: FastifyPluginAsync = async (fastify) => { + // POST /social/likes/:planId — like a plan + fastify.post<{ Params: { planId: string } }>('/social/likes/:planId', { + preHandler: [requireAuth], + }, async (request, reply) => { + const { planId } = request.params; + + const plan = await fastify.prisma.streamPlan.findUnique({ where: { id: planId } }); + if (!plan) throw new AppError(404, 'Plan not found'); + + await fastify.prisma.like.upsert({ + where: { + userId_planId: { + userId: request.userId, + planId, + }, + }, + update: {}, + create: { + userId: request.userId, + planId, + }, + }); + + reply.status(200).send({ success: true }); + }); + + // DELETE /social/likes/:planId — unlike a plan + fastify.delete<{ Params: { planId: string } }>('/social/likes/:planId', { + preHandler: [requireAuth], + }, async (request, reply) => { + const { planId } = request.params; + + await fastify.prisma.like.deleteMany({ + where: { + userId: request.userId, + planId, + }, + }); + + reply.status(200).send({ success: true }); + }); + + // GET /social/likes/:planId — like count + isLiked + fastify.get<{ Params: { planId: string } }>('/social/likes/:planId', { + preHandler: [requireAuth], + }, async (request) => { + const { planId } = request.params; + + const [count, myLike] = await Promise.all([ + fastify.prisma.like.count({ where: { planId } }), + fastify.prisma.like.findUnique({ + where: { + userId_planId: { + userId: request.userId, + planId, + }, + }, + }), + ]); + + const response: LikeStatusResponse = { + count, + isLiked: !!myLike, + }; + return response; + }); +}; + +export default likesRoutes; diff --git a/src/services/chat-manager.service.ts b/src/services/chat-manager.service.ts index 264f071..56379cf 100644 --- a/src/services/chat-manager.service.ts +++ b/src/services/chat-manager.service.ts @@ -73,8 +73,16 @@ async function getDecryptedToken( }; } +interface PortalSubscriber { + userId: string; + displayName: string; + avatarUrl: string | null; + socket: WebSocket; +} + export class ChatManager { private sessions = new Map(); // key: `${userId}:${planId}` + private portalSubscribers = new Map>(); // key: planId private prisma: PrismaClient; private logger: FastifyBaseLogger; @@ -184,6 +192,106 @@ export class ChatManager { }); } + async subscribePortalChat(planId: string, userId: string, socket: WebSocket): Promise { + const user = await (this.prisma as any).user.findUnique({ where: { id: userId } }); + if (!user) return; + + if (!this.portalSubscribers.has(planId)) { + this.portalSubscribers.set(planId, new Set()); + } + + // Remove existing subscription for this user+plan + const subs = this.portalSubscribers.get(planId)!; + for (const sub of subs) { + if (sub.userId === userId) { + subs.delete(sub); + break; + } + } + + subs.add({ + userId, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + socket, + }); + + this.logger.info({ planId, userId }, 'Portal chat subscribed'); + } + + async handlePortalComment(planId: string, userId: string, text: string): Promise { + const user = await (this.prisma as any).user.findUnique({ where: { id: userId } }); + if (!user) return; + + const message = { + id: `portal-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`, + authorName: user.displayName, + authorImageUrl: user.avatarUrl, + text, + timestamp: Date.now(), + isModerator: false, + isBroadcaster: false, + color: '#00BCD4', + }; + + // Broadcast to all portal subscribers watching this plan + const subs = this.portalSubscribers.get(planId); + if (subs) { + for (const sub of subs) { + this.sendToSocket(sub.socket, { + type: 'chat_message', + planId, + service: 'PORTAL', + destinationId: 'portal', + message, + }); + } + } + + // Also send to the plan owner's chat session (so it shows up in their Android app) + const plan = await (this.prisma as any).streamPlan.findUnique({ where: { id: planId } }); + if (plan) { + const ownerSession = this.sessions.get(`${plan.userId}:${planId}`); + if (ownerSession) { + this.sendToSocket(ownerSession.socket, { + type: 'chat_message', + planId, + service: 'PORTAL', + destinationId: 'portal', + message, + }); + } + } + } + + async handleLike(planId: string, userId: string): Promise { + // Toggle like in DB + const existing = await (this.prisma as any).like.findUnique({ + where: { userId_planId: { userId, planId } }, + }); + + if (existing) { + await (this.prisma as any).like.delete({ where: { id: existing.id } }); + } else { + await (this.prisma as any).like.create({ data: { userId, planId } }); + } + + const count = await (this.prisma as any).like.count({ where: { planId } }); + + // Broadcast like update to all portal subscribers + const subs = this.portalSubscribers.get(planId); + if (subs) { + for (const sub of subs) { + this.sendToSocket(sub.socket, { + type: 'like_update', + planId, + count, + isLiked: sub.userId === userId ? !existing : undefined, + }); + } + } + } + async stopChat(planId: string, userId: string): Promise { const sessionKey = `${userId}:${planId}`; const session = this.sessions.get(sessionKey); @@ -222,6 +330,18 @@ export class ChatManager { this.sessions.delete(key); } } + + // Clean up portal subscriptions for this socket + for (const [planId, subs] of this.portalSubscribers) { + for (const sub of subs) { + if (sub.socket === socket) { + subs.delete(sub); + } + } + if (subs.size === 0) { + this.portalSubscribers.delete(planId); + } + } } private async startYouTubeChat(session: ChatSession, dest: any): Promise { diff --git a/src/services/meta-web-auth.service.ts b/src/services/meta-web-auth.service.ts new file mode 100644 index 0000000..c2faa4e --- /dev/null +++ b/src/services/meta-web-auth.service.ts @@ -0,0 +1,66 @@ +import { config } from '../config.js'; + +interface FacebookTokenResponse { + access_token: string; + token_type: string; + expires_in: number; +} + +interface FacebookProfile { + id: string; + name: string; + email?: string; + picture?: { data?: { url?: string } }; +} + +export async function exchangeFacebookCode(code: string): Promise<{ accessToken: string; expiresIn: number }> { + const params = new URLSearchParams({ + client_id: config.metaWeb.clientId, + client_secret: config.metaWeb.clientSecret, + redirect_uri: config.metaWeb.redirectUri, + code, + }); + + const res = await fetch(`https://graph.facebook.com/v19.0/oauth/access_token?${params}`); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Facebook token exchange failed: ${res.status} ${text}`); + } + + const data = (await res.json()) as FacebookTokenResponse; + return { accessToken: data.access_token, expiresIn: data.expires_in }; +} + +export async function fetchFacebookProfile(accessToken: string): Promise<{ + facebookId: string; + displayName: string; + email: string | null; + avatarUrl: string | null; +}> { + const res = await fetch( + `https://graph.facebook.com/v19.0/me?fields=id,name,picture.type(large)&access_token=${accessToken}`, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Facebook profile fetch failed: ${res.status} ${text}`); + } + + const data = (await res.json()) as FacebookProfile; + return { + facebookId: data.id, + displayName: data.name, + email: data.email ?? null, + avatarUrl: data.picture?.data?.url ?? null, + }; +} + +export function getFacebookOAuthUrl(state: string): string { + const params = new URLSearchParams({ + client_id: config.metaWeb.clientId, + redirect_uri: config.metaWeb.redirectUri, + state, + scope: 'public_profile', + response_type: 'code', + }); + return `https://www.facebook.com/v19.0/dialog/oauth?${params}`; +} diff --git a/src/types/api.ts b/src/types/api.ts index cb7a346..88e8e53 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -20,6 +20,31 @@ export interface UserProfileResponse { displayName: string; email: string | null; avatarUrl: string | null; + bio: string; + isPublic: boolean; + hasQuestLink: boolean; + hasFacebookLink: boolean; +} + +export interface UpdateProfileBody { + displayName?: string; + bio?: string; + isPublic?: boolean; +} + +export interface MetaWebCallbackBody { + code: string; + state: string; +} + +export interface LinkQuestBody { + metaId: string; + nonce?: string; +} + +export interface LinkFacebookBody { + code: string; + state: string; } // ── Providers ──────────────────────────────────────────── @@ -113,3 +138,36 @@ export interface PreparedDestination { streamKey: string; broadcastId: string; } + +// ── Social ────────────────────────────────────────────── +export interface PublicUserResponse { + id: string; + displayName: string; + avatarUrl: string | null; + bio: string; + followerCount: number; + followingCount: number; + isFollowing: boolean; +} + +export interface FeedItemResponse { + plan: StreamPlanResponse & { user: { id: string; displayName: string; avatarUrl: string | null } }; + likeCount: number; + commentCount: number; + isLiked: boolean; +} + +export interface FeedResponse { + items: FeedItemResponse[]; + nextCursor: string | null; +} + +export interface LikeStatusResponse { + count: number; + isLiked: boolean; +} + +export interface FollowListResponse { + users: PublicUserResponse[]; + nextCursor: string | null; +}