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
This commit is contained in:
2026-03-02 12:32:39 +01:00
parent 6931670a1f
commit 7ce1c2a8bc
17 changed files with 1060 additions and 26 deletions

View File

@@ -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

View File

@@ -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");

View File

@@ -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])
}

View File

@@ -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;

View File

@@ -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;

104
src/routes/auth/link.ts Normal file
View File

@@ -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;

128
src/routes/auth/meta-web.ts Normal file
View File

@@ -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<string, { expiresAt: number }>();
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;

View File

@@ -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);

View File

@@ -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}` }));
}

View File

@@ -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<string, { userId: string; expiresAt: number }>();
// In-memory CSRF state store (state → { userId, expiresAt, source })
const pendingStates = new Map<string, { userId: string; expiresAt: number; source?: string }>();
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);
},
);

View File

@@ -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<string, { userId: string; expiresAt: number }>();
// In-memory CSRF state store (state → { userId, expiresAt, source })
const pendingStates = new Map<string, { userId: string; expiresAt: number; source?: string }>();
// 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);
},
);

98
src/routes/social/feed.ts Normal file
View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<string, ChatSession>(); // key: `${userId}:${planId}`
private portalSubscribers = new Map<string, Set<PortalSubscriber>>(); // key: planId
private prisma: PrismaClient;
private logger: FastifyBaseLogger;
@@ -184,6 +192,106 @@ export class ChatManager {
});
}
async subscribePortalChat(planId: string, userId: string, socket: WebSocket): Promise<void> {
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<void> {
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<void> {
// 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<void> {
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<void> {

View File

@@ -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}`;
}

View File

@@ -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;
}