- Add POST /providers/accounts/custom-rtmp endpoint for saved RTMP servers - Encrypt rtmpUrl/streamKey in accessTokenEnc/refreshTokenEnc fields - Decrypt and return rtmpUrl/streamKey in GET /providers/accounts for CUSTOM_RTMP - Skip token revocation on DELETE for CUSTOM_RTMP accounts - Decrypt CUSTOM_RTMP credentials into CUSTOM destinations on plan create/update - Handle CUSTOM destinations in prepare lifecycle (already READY, skip provider auth) - Add debug logging for plan operations and user upsert
133 lines
4.1 KiB
TypeScript
133 lines
4.1 KiB
TypeScript
import { FastifyPluginAsync } from 'fastify';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { requireAuth } from '../../middleware/require-auth.js';
|
|
import { decrypt, encrypt } from '../../services/crypto.service.js';
|
|
import { revokeYouTubeToken } from '../../services/youtube.service.js';
|
|
import { revokeTwitchToken } from '../../services/twitch.service.js';
|
|
import { AppError } from '../../plugins/error-handler.js';
|
|
import type { LinkedAccountResponse, CreateCustomRtmpBody } from '../../types/api.js';
|
|
|
|
const accountRoutes: FastifyPluginAsync = async (fastify) => {
|
|
// GET /providers/accounts — list linked accounts (no tokens, except CUSTOM_RTMP)
|
|
fastify.get('/providers/accounts', {
|
|
preHandler: [requireAuth],
|
|
}, async (request) => {
|
|
const accounts = await fastify.prisma.linkedAccount.findMany({
|
|
where: { userId: request.userId },
|
|
});
|
|
|
|
const response: LinkedAccountResponse[] = accounts.map((a) => {
|
|
const base: LinkedAccountResponse = {
|
|
id: a.id,
|
|
serviceId: a.serviceId,
|
|
displayName: a.displayName,
|
|
accountId: a.accountId,
|
|
avatarUrl: a.avatarUrl,
|
|
};
|
|
if (a.serviceId === 'CUSTOM_RTMP') {
|
|
base.rtmpUrl = decrypt(a.accessTokenEnc, a.accessTokenIv);
|
|
base.streamKey = decrypt(a.refreshTokenEnc, a.refreshTokenIv);
|
|
}
|
|
return base;
|
|
});
|
|
|
|
return response;
|
|
});
|
|
|
|
// POST /providers/accounts/custom-rtmp — create a custom RTMP account
|
|
fastify.post<{ Body: CreateCustomRtmpBody }>('/providers/accounts/custom-rtmp', {
|
|
preHandler: [requireAuth],
|
|
schema: {
|
|
body: {
|
|
type: 'object',
|
|
required: ['displayName', 'rtmpUrl', 'streamKey'],
|
|
properties: {
|
|
displayName: { type: 'string', minLength: 1, maxLength: 200 },
|
|
rtmpUrl: { type: 'string', minLength: 1, maxLength: 500 },
|
|
streamKey: { type: 'string', minLength: 1, maxLength: 500 },
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
}, async (request, reply) => {
|
|
const { displayName, rtmpUrl, streamKey } = request.body;
|
|
|
|
const urlEnc = encrypt(rtmpUrl);
|
|
const keyEnc = encrypt(streamKey);
|
|
|
|
const account = await fastify.prisma.linkedAccount.create({
|
|
data: {
|
|
userId: request.userId,
|
|
serviceId: 'CUSTOM_RTMP',
|
|
accountId: randomUUID(),
|
|
displayName,
|
|
avatarUrl: null,
|
|
accessTokenEnc: urlEnc.ciphertext,
|
|
accessTokenIv: urlEnc.iv,
|
|
refreshTokenEnc: keyEnc.ciphertext,
|
|
refreshTokenIv: keyEnc.iv,
|
|
tokenExpiresAt: new Date('2099-12-31T00:00:00Z'),
|
|
},
|
|
});
|
|
|
|
const response: LinkedAccountResponse = {
|
|
id: account.id,
|
|
serviceId: account.serviceId,
|
|
displayName: account.displayName,
|
|
accountId: account.accountId,
|
|
avatarUrl: account.avatarUrl,
|
|
rtmpUrl,
|
|
streamKey,
|
|
};
|
|
|
|
reply.status(201).send(response);
|
|
});
|
|
|
|
// DELETE /providers/accounts/:id — revoke tokens and unlink by account ID
|
|
fastify.delete<{ Params: { id: string } }>('/providers/accounts/:id', {
|
|
preHandler: [requireAuth],
|
|
schema: {
|
|
params: {
|
|
type: 'object',
|
|
required: ['id'],
|
|
properties: {
|
|
id: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
}, async (request, reply) => {
|
|
const account = await fastify.prisma.linkedAccount.findFirst({
|
|
where: {
|
|
id: request.params.id,
|
|
userId: request.userId,
|
|
},
|
|
});
|
|
|
|
if (!account) {
|
|
throw new AppError(404, 'Account not linked');
|
|
}
|
|
|
|
// Best-effort revoke tokens at the provider (skip for CUSTOM_RTMP)
|
|
if (account.serviceId !== 'CUSTOM_RTMP') {
|
|
try {
|
|
const accessToken = decrypt(account.accessTokenEnc, account.accessTokenIv);
|
|
if (account.serviceId === 'YOUTUBE') {
|
|
await revokeYouTubeToken(accessToken);
|
|
} else {
|
|
await revokeTwitchToken(accessToken);
|
|
}
|
|
} catch {
|
|
// Revocation failure is non-fatal
|
|
}
|
|
}
|
|
|
|
await fastify.prisma.linkedAccount.delete({
|
|
where: { id: account.id },
|
|
});
|
|
|
|
reply.status(200).send({ success: true });
|
|
});
|
|
};
|
|
|
|
export default accountRoutes;
|