From ed83c651d8d5e865e7f5cb6008d95941b7db6809 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Mon, 2 Mar 2026 23:07:24 +0100 Subject: [PATCH] Pairing code auth, replace Facebook OAuth, public feed - Add PairingCode model, POST /generate + /redeem + GET /status endpoints - Remove facebookId from User, make metaId non-nullable - Delete meta-web routes, link routes, meta-web-auth service - Remove metaWeb config block and hasFacebookLink from responses - Add optionalAuth middleware, make feed publicly accessible - Resolve Twitch channel names for embed broadcastIds --- prisma/schema.prisma | 15 +- src/app.ts | 8 +- src/config.ts | 6 - src/middleware/require-auth.ts | 10 ++ src/routes/auth/link.ts | 104 ------------- src/routes/auth/meta-web.ts | 128 --------------- src/routes/auth/pairing.ts | 133 ++++++++++++++++ src/routes/auth/session.ts | 4 - src/routes/pages.ts | 215 ++++++++++++++++++++++++++ src/routes/social/feed.ts | 52 +++++-- src/services/meta-web-auth.service.ts | 66 -------- src/types/api.ts | 14 +- 12 files changed, 417 insertions(+), 338 deletions(-) delete mode 100644 src/routes/auth/link.ts delete mode 100644 src/routes/auth/meta-web.ts create mode 100644 src/routes/auth/pairing.ts create mode 100644 src/routes/pages.ts delete mode 100644 src/services/meta-web-auth.service.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fcd04cd..a87e89a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,8 +9,7 @@ datasource db { model User { id String @id @default(uuid()) - metaId String? @unique - facebookId String? @unique + metaId String @unique displayName String email String? avatarUrl String? @@ -22,11 +21,23 @@ model User { linkedAccounts LinkedAccount[] streamPlans StreamPlan[] sessions Session[] + pairingCodes PairingCode[] followers Follow[] @relation("following") following Follow[] @relation("follower") likes Like[] } +model PairingCode { + id String @id @default(uuid()) + code String @unique + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([userId]) +} + model Session { id String @id @default(uuid()) userId String diff --git a/src/app.ts b/src/app.ts index 1d713a2..02a548e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,11 +5,11 @@ import websocket from '@fastify/websocket'; import prismaPlugin from './plugins/prisma.js'; import errorHandlerPlugin from './plugins/error-handler.js'; import authPlugin from './plugins/auth.js'; +import pageRoutes from './routes/pages.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 pairingRoutes from './routes/auth/pairing.js'; import accountRoutes from './routes/providers/accounts.js'; import youtubeRoutes from './routes/providers/youtube.js'; import twitchRoutes from './routes/providers/twitch.js'; @@ -48,11 +48,11 @@ export async function buildApp() { const chatManager = new ChatManager(app.prisma, app.log); // Routes + await app.register(pageRoutes); await app.register(healthRoutes); await app.register(metaAuthRoutes); - await app.register(metaWebRoutes); await app.register(sessionRoutes); - await app.register(linkRoutes); + await app.register(pairingRoutes); await app.register(accountRoutes); await app.register(youtubeRoutes); await app.register(twitchRoutes); diff --git a/src/config.ts b/src/config.ts index 1abcc7c..4284bf0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -38,12 +38,6 @@ 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', '*'), diff --git a/src/middleware/require-auth.ts b/src/middleware/require-auth.ts index 6bc06fb..4e8e9e9 100644 --- a/src/middleware/require-auth.ts +++ b/src/middleware/require-auth.ts @@ -14,3 +14,13 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply) reply.status(401).send({ error: 'Invalid or expired token' }); } } + +export async function optionalAuth(request: FastifyRequest) { + const header = request.headers.authorization; + if (!header?.startsWith('Bearer ')) return; + try { + request.userId = await verifyAccessToken(header.slice(7)); + } catch { + // not authenticated — continue as anonymous + } +} diff --git a/src/routes/auth/link.ts b/src/routes/auth/link.ts deleted file mode 100644 index 653d630..0000000 --- a/src/routes/auth/link.ts +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index 8ba09be..0000000 --- a/src/routes/auth/meta-web.ts +++ /dev/null @@ -1,128 +0,0 @@ -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/pairing.ts b/src/routes/auth/pairing.ts new file mode 100644 index 0000000..7821d9e --- /dev/null +++ b/src/routes/auth/pairing.ts @@ -0,0 +1,133 @@ +import { FastifyPluginAsync } from 'fastify'; +import { randomUUID, randomInt } from 'node:crypto'; +import { signAccessToken } from '../../plugins/auth.js'; +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 { AuthTokensResponse, PairingGenerateResponse, PairingRedeemBody } from '../../types/api.js'; + +const PAIRING_CODE_TTL = 15 * 60 * 1000; // 15 minutes + +const pairingRoutes: FastifyPluginAsync = async (fastify) => { + // POST /auth/pairing/generate — create a 6-digit pairing code (requires auth) + fastify.post('/auth/pairing/generate', { + preHandler: [requireAuth], + }, async (request, reply) => { + // Cleanup expired codes globally + await fastify.prisma.pairingCode.deleteMany({ + where: { expiresAt: { lt: new Date() } }, + }); + + // Delete any existing codes for this user + await fastify.prisma.pairingCode.deleteMany({ + where: { userId: request.userId }, + }); + + // Generate unique 6-digit code + let code: string; + let attempts = 0; + do { + code = String(randomInt(100000, 999999)); + const existing = await fastify.prisma.pairingCode.findUnique({ where: { code } }); + if (!existing) break; + attempts++; + } while (attempts < 10); + + if (attempts >= 10) { + throw new AppError(500, 'Failed to generate unique pairing code'); + } + + const expiresAt = new Date(Date.now() + PAIRING_CODE_TTL); + + await fastify.prisma.pairingCode.create({ + data: { + code, + userId: request.userId, + expiresAt, + }, + }); + + const response: PairingGenerateResponse = { + code, + expiresAt: expiresAt.toISOString(), + }; + + reply.status(200).send(response); + }); + + // GET /auth/pairing/status — check if user's pairing code is still active + fastify.get('/auth/pairing/status', { + preHandler: [requireAuth], + }, async (request, reply) => { + const code = await fastify.prisma.pairingCode.findFirst({ + where: { userId: request.userId, expiresAt: { gt: new Date() } }, + }); + + reply.status(200).send({ + active: !!code, + code: code?.code ?? null, + expiresAt: code?.expiresAt.toISOString() ?? null, + }); + }); + + // POST /auth/pairing/redeem — exchange a pairing code for tokens (public, rate-limited) + fastify.post<{ Body: PairingRedeemBody }>('/auth/pairing/redeem', { + config: { + rateLimit: { max: 10, timeWindow: '1 minute' }, + }, + schema: { + body: { + type: 'object', + required: ['code'], + properties: { + code: { type: 'string', minLength: 6, maxLength: 6, pattern: '^[0-9]{6}$' }, + }, + additionalProperties: false, + }, + }, + }, async (request, reply) => { + const { code } = request.body; + + const pairingCode = await fastify.prisma.pairingCode.findUnique({ + where: { code }, + }); + + if (!pairingCode) { + throw new AppError(400, 'Invalid pairing code'); + } + + if (pairingCode.expiresAt < new Date()) { + await fastify.prisma.pairingCode.delete({ where: { id: pairingCode.id } }); + throw new AppError(400, 'Pairing code has expired'); + } + + // Delete code (single use) + await fastify.prisma.pairingCode.delete({ where: { id: pairingCode.id } }); + + // Create session + const refreshToken = randomUUID(); + const expiresAt = new Date(Date.now() + config.jwt.refreshTtl * 1000); + + await fastify.prisma.session.create({ + data: { + userId: pairingCode.userId, + refreshToken: hashToken(refreshToken), + expiresAt, + deviceInfo: 'web-portal', + }, + }); + + const accessToken = await signAccessToken(pairingCode.userId); + + const response: AuthTokensResponse = { + accessToken, + refreshToken, + expiresIn: config.jwt.accessTtl, + }; + + reply.status(200).send(response); + }); +}; + +export default pairingRoutes; diff --git a/src/routes/auth/session.ts b/src/routes/auth/session.ts index e0a7abb..e8e5cc4 100644 --- a/src/routes/auth/session.ts +++ b/src/routes/auth/session.ts @@ -85,8 +85,6 @@ const sessionRoutes: FastifyPluginAsync = async (fastify) => { avatarUrl: user.avatarUrl, bio: user.bio, isPublic: user.isPublic, - hasQuestLink: !!user.metaId, - hasFacebookLink: !!user.facebookId, }; reply.status(200).send(response); @@ -124,8 +122,6 @@ const sessionRoutes: FastifyPluginAsync = async (fastify) => { 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/pages.ts b/src/routes/pages.ts new file mode 100644 index 0000000..f84dd1f --- /dev/null +++ b/src/routes/pages.ts @@ -0,0 +1,215 @@ +import { FastifyPluginAsync } from 'fastify'; +import { config } from '../config.js'; + +const portalUrl = config.portalUrl || 'https://portal.omigame.dev'; + +function layout(title: string, body: string): string { + return ` + + + + + ${title} + + + +
+ ${body} + +
+ +`; +} + +const pageRoutes: FastifyPluginAsync = async (fastify) => { + // Landing page + fastify.get('/', async (_request, reply) => { + reply.header('content-type', 'text/html; charset=utf-8').send(layout('LCK Control', + `

LCK Control

+

Stream management platform for Meta Quest

+ +
+

What is LCK Control?

+

LCK Control is a live streaming management platform that lets you broadcast gameplay from your Meta Quest headset to YouTube, Twitch, and custom RTMP destinations simultaneously. It consists of:

+
    +
  • Companion App — An Android app on your Quest headset that captures and streams gameplay to multiple platforms at once.
  • +
  • Web Portal — A website at ${portalUrl.replace('https://', '')} where you can manage stream plans, link accounts, and discover other users' live streams.
  • +
  • Backend API — This server that handles authentication, stream lifecycle management, and real-time chat integration.
  • +
+
+ +
+

Features

+
    +
  • Multi-platform streaming — broadcast to YouTube, Twitch, and custom RTMP servers simultaneously.
  • +
  • Stream plan management — create, prepare, start, and end streams from your headset or the web portal.
  • +
  • Live chat integration — view and respond to YouTube, Twitch, and portal comments in real-time.
  • +
  • Discovery feed — browse and watch other users' live streams and VODs in a TikTok-style vertical feed.
  • +
  • Social features — follow users, like streams, and leave comments.
  • +
+
+ + ` + )); + }); + + // Privacy policy + fastify.get('/privacy', async (_request, reply) => { + reply.header('content-type', 'text/html; charset=utf-8').send(layout('Privacy Policy — LCK Control', + `

Privacy Policy

+

Last updated: March 2, 2026

+ +
+

1. Information We Collect

+

When you use LCK Control, we may collect the following information:

+
    +
  • Account information: Your name, profile picture, and email address from your Meta (Facebook) or Meta Quest account when you log in.
  • +
  • Linked service data: When you connect YouTube or Twitch accounts, we store access tokens to manage streams on your behalf. We do not store your passwords.
  • +
  • Usage data: Stream plans you create, likes, follows, and comments you make on the platform.
  • +
+
+ +
+

2. How We Use Your Information

+
    +
  • To authenticate you and provide access to the platform.
  • +
  • To create and manage live streams on YouTube and Twitch on your behalf.
  • +
  • To display your public profile and live streams to other users (when you opt in to public discovery).
  • +
  • To enable social features such as likes, comments, and follows.
  • +
+
+ +
+

3. Data Sharing

+

We do not sell your personal information. We share data only with:

+
    +
  • YouTube API Services: To manage your live broadcasts. YouTube API Services are subject to Google's Privacy Policy.
  • +
  • Twitch API: To manage your Twitch streams.
  • +
  • Meta Platform: For authentication purposes.
  • +
+
+ +
+

4. Data Storage & Security

+

Your data is stored on our secure servers. Access tokens for linked services are encrypted at rest using AES-256-GCM. We retain your data as long as your account is active.

+
+ +
+

5. Your Rights

+

You can:

+
    +
  • Unlink any connected service (YouTube, Twitch) at any time from the Accounts page.
  • +
  • Toggle your public visibility on or off.
  • +
  • Request deletion of your account and all associated data by contacting us.
  • +
+
+ +
+

6. Google API Services Disclosure

+

LCK Control's use and transfer to any other app of information received from Google APIs will adhere to the Google API Services User Data Policy, including the Limited Use requirements.

+
+ +
+

7. Contact

+

If you have questions about this privacy policy, please contact us at omar@omigame.dev.

+
` + )); + }); + + // Terms of service + fastify.get('/terms', async (_request, reply) => { + reply.header('content-type', 'text/html; charset=utf-8').send(layout('Terms of Service — LCK Control', + `

Terms of Service

+

Last updated: March 2, 2026

+ +
+

1. Acceptance of Terms

+

By accessing or using LCK Control ("the Service"), you agree to be bound by these Terms of Service. If you do not agree, do not use the Service.

+
+ +
+

2. Description of Service

+

LCK Control is a live streaming management platform that allows you to broadcast gameplay from your Meta Quest headset to multiple platforms (YouTube, Twitch) simultaneously, discover other users' live streams, and interact through likes and comments.

+
+ +
+

3. Account & Access

+
    +
  • You must log in with a valid Meta (Facebook) or Meta Quest account to access authenticated features.
  • +
  • You are responsible for maintaining the security of your account.
  • +
  • You must not share your account or use the Service on behalf of others without authorization.
  • +
+
+ +
+

4. Linked Services

+

When you link YouTube or Twitch accounts, you authorize LCK Control to manage streams on your behalf. You remain subject to the terms of service of those platforms. You can unlink any service at any time.

+
+ +
+

5. User Conduct

+

You agree not to:

+
    +
  • Use the Service for any unlawful purpose.
  • +
  • Stream content that violates the terms of YouTube, Twitch, or applicable law.
  • +
  • Harass, abuse, or post harmful content in comments.
  • +
  • Attempt to gain unauthorized access to the Service or its systems.
  • +
+
+ +
+

6. Content & Intellectual Property

+

You retain ownership of the content you stream. By making your streams public on the portal, you grant other users the ability to view, like, and comment on them. We do not claim ownership of your content.

+
+ +
+

7. Disclaimer of Warranties

+

The Service is provided "as is" without warranties of any kind. We do not guarantee uninterrupted or error-free operation. Stream management depends on third-party APIs (YouTube, Twitch) which may have their own limitations.

+
+ +
+

8. Limitation of Liability

+

To the maximum extent permitted by law, we shall not be liable for any indirect, incidental, or consequential damages arising from your use of the Service, including but not limited to failed streams, data loss, or service interruptions.

+
+ +
+

9. Termination

+

We may suspend or terminate your access to the Service at any time for violation of these terms. You may stop using the Service at any time by unlinking your accounts and discontinuing use.

+
+ +
+

10. Changes to Terms

+

We may update these terms from time to time. Continued use of the Service after changes constitutes acceptance of the updated terms.

+
+ +
+

11. Contact

+

If you have questions about these terms, please contact us at omar@omigame.dev.

+
` + )); + }); +}; + +export default pageRoutes; diff --git a/src/routes/social/feed.ts b/src/routes/social/feed.ts index b3d83aa..82536c6 100644 --- a/src/routes/social/feed.ts +++ b/src/routes/social/feed.ts @@ -1,30 +1,33 @@ import { FastifyPluginAsync } from 'fastify'; -import { requireAuth } from '../../middleware/require-auth.js'; +import { optionalAuth } 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], + preHandler: [optionalAuth], }, 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 + // Base condition: LIVE + ENDED plans from public users const baseWhere: any = { - status: 'LIVE', + status: { in: ['LIVE', 'ENDED'] }, user: { isPublic: true }, }; - // If following filter, restrict to followed users - if (filter === 'following') { + // If following filter, restrict to followed users (requires auth) + if (filter === 'following' && request.userId) { 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 }; + } else if (filter === 'following' && !request.userId) { + // Not logged in — return empty following feed + return { items: [], nextCursor: null } as FeedResponse; } let orderBy: any; @@ -49,13 +52,33 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => { const hasMore = plans.length > limit; const items = plans.slice(0, limit); - // Check which plans the current user has liked + // Check which plans the current user has liked (only if authenticated) 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)); + let likedSet = new Set(); + if (request.userId) { + const myLikes = await fastify.prisma.like.findMany({ + where: { userId: request.userId, planId: { in: planIds } }, + select: { planId: true }, + }); + likedSet = new Set(myLikes.map(l => l.planId)); + } + + // Resolve Twitch channel names from linked accounts + const twitchAccountIds = new Set(); + for (const plan of items) { + for (const d of plan.destinations) { + if (d.serviceId === 'TWITCH' && d.linkedAccountId) { + twitchAccountIds.add(d.linkedAccountId); + } + } + } + const twitchAccounts = twitchAccountIds.size > 0 + ? await fastify.prisma.linkedAccount.findMany({ + where: { id: { in: [...twitchAccountIds] } }, + select: { id: true, displayName: true }, + }) + : []; + const twitchNameMap = new Map(twitchAccounts.map(a => [a.id, a.displayName])); const feedItems: FeedItemResponse[] = items.map(plan => ({ plan: { @@ -77,7 +100,10 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => { tags: d.tags, rtmpUrl: '', streamKey: '', - broadcastId: d.broadcastId, + // Twitch embed needs channel name, not numeric account ID + broadcastId: d.serviceId === 'TWITCH' + ? (twitchNameMap.get(d.linkedAccountId) || d.broadcastId) + : d.broadcastId, status: d.status, })), user: plan.user, diff --git a/src/services/meta-web-auth.service.ts b/src/services/meta-web-auth.service.ts deleted file mode 100644 index c2faa4e..0000000 --- a/src/services/meta-web-auth.service.ts +++ /dev/null @@ -1,66 +0,0 @@ -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 88e8e53..a3b9230 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -22,8 +22,6 @@ export interface UserProfileResponse { avatarUrl: string | null; bio: string; isPublic: boolean; - hasQuestLink: boolean; - hasFacebookLink: boolean; } export interface UpdateProfileBody { @@ -32,19 +30,13 @@ export interface UpdateProfileBody { isPublic?: boolean; } -export interface MetaWebCallbackBody { +export interface PairingGenerateResponse { code: string; - state: string; + expiresAt: string; } -export interface LinkQuestBody { - metaId: string; - nonce?: string; -} - -export interface LinkFacebookBody { +export interface PairingRedeemBody { code: string; - state: string; } // ── Providers ────────────────────────────────────────────