Custom RTMP saved accounts, CUSTOM destination prepare, debug logging
- 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
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import { decrypt } from '../../services/crypto.service.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 } from '../../types/api.js';
|
||||
import type { LinkedAccountResponse, CreateCustomRtmpBody } from '../../types/api.js';
|
||||
|
||||
const accountRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// GET /providers/accounts — list linked accounts (no tokens)
|
||||
// GET /providers/accounts — list linked accounts (no tokens, except CUSTOM_RTMP)
|
||||
fastify.get('/providers/accounts', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request) => {
|
||||
@@ -15,17 +16,73 @@ const accountRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
where: { userId: request.userId },
|
||||
});
|
||||
|
||||
const response: LinkedAccountResponse[] = accounts.map((a) => ({
|
||||
id: a.id,
|
||||
serviceId: a.serviceId,
|
||||
displayName: a.displayName,
|
||||
accountId: a.accountId,
|
||||
avatarUrl: a.avatarUrl,
|
||||
}));
|
||||
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],
|
||||
@@ -50,16 +107,18 @@ const accountRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
throw new AppError(404, 'Account not linked');
|
||||
}
|
||||
|
||||
// Best-effort revoke tokens at the provider
|
||||
try {
|
||||
const accessToken = decrypt(account.accessTokenEnc, account.accessTokenIv);
|
||||
if (account.serviceId === 'YOUTUBE') {
|
||||
await revokeYouTubeToken(accessToken);
|
||||
} else {
|
||||
await revokeTwitchToken(accessToken);
|
||||
// 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
|
||||
}
|
||||
} catch {
|
||||
// Revocation failure is non-fatal
|
||||
}
|
||||
|
||||
await fastify.prisma.linkedAccount.delete({
|
||||
|
||||
Reference in New Issue
Block a user