Sharkey/packages/backend/src/server/StripeHookServerService.ts
2024-09-14 14:40:00 +02:00

201 lines
5.5 KiB
TypeScript

/*
* SPDX-FileCopyrightText: marie and sharkey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type {
UserProfilesRepository,
UsersRepository,
} from '@/models/_.js';
import type { Config } from '@/config.js';
import type { MiLocalUser } from '@/models/User.js';
import { bindThis } from '@/decorators.js';
import cors from '@fastify/cors';
import secureJson from 'secure-json-parse';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify';
import Stripe from 'stripe';
import type Logger from '@/logger.js';
import { LoggerService } from '@/core/LoggerService.js';
import { RoleService } from '@/core/RoleService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
@Injectable()
export class StripeHookServerService {
private logger: Logger;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private loggerService: LoggerService,
private globalEventService: GlobalEventService,
private roleService: RoleService,
) {
this.logger = this.loggerService.getLogger('stripe', 'gray');
}
@bindThis
private async stripehook(
request: FastifyRequest,
reply: FastifyReply,
) {
if (!this.config.stripeAgeCheck.enabled) return reply.code(400);
if (request.rawBody == null) {
// Bad request
reply.code(400);
return;
}
function error(status: number, error: { id: string }) {
reply.code(status);
return { error };
}
const stripe = new Stripe(this.config.stripeAgeCheck.key);
const body = request.rawBody;
const headers = request.headers;
let event;
// Verify the event came from Stripe
try {
const sig = headers['stripe-signature']!;
event = stripe.webhooks.constructEvent(body, sig, this.config.stripeAgeCheck.hookKey);
} catch (err: any) {
// On error, log and return the error message
this.logger.error(`❌ Stripe Error`, err);
return reply.code(400).send(`Webhook Error: ${err.message}`);
}
// Successfully constructed event
switch (event.type) {
case 'identity.verification_session.verified': {
// All the verification checks passed
const verificationSession = event.data.object;
const user = await this.usersRepository.findOneBy({
id: verificationSession.metadata.user_id,
host: IsNull(),
}) as MiLocalUser;
if (user == null) {
return error(404, {
id: '6cc579cc-885d-43d8-95c2-b8c7fc963280',
});
}
if (user.isSuspended) {
return error(403, {
id: 'e03a5f46-d309-4865-9b69-56282d94e1eb',
});
}
this.logger.succ(`${user.username} has succesfully approved their ID via Session ${verificationSession.id}`);
await this.usersRepository.update(user.id, { idCheckRequired: false, idVerified: true });
break;
}
case 'identity.verification_session.requires_input': {
// Verification failed for some reason
const verificationSession = event.data.object;
const user = await this.usersRepository.findOneBy({
id: verificationSession.metadata.user_id,
host: IsNull(),
}) as MiLocalUser;
if (user == null) {
return error(404, {
id: '6cc579cc-885d-43d8-95c2-b8c7fc963280',
});
}
if (user.isSuspended) {
return error(403, {
id: 'e03a5f46-d309-4865-9b69-56282d94e1eb',
});
}
this.logger.succ(`${user.username} has failed ID Verification via Session ${verificationSession.id}`);
// If general instance then unset idCheckRequired as to prevent locking the user out forever admins/mods can see the mod note in case of the failure
if (!this.config.stripeAgeCheck.required) await this.usersRepository.update(user.id, { idCheckRequired: false });
await this.userProfilesRepository.update(user.id, { moderationNote: 'ADM/IDFAIL: Possibly underage' });
const moderatorIds = await this.roleService.getModeratorIds(true, true);
for (const moderatorId of moderatorIds) {
this.globalEventService.publishAdminStream(
moderatorId,
'failedIdCheck',
{
userId: user.id,
comment: 'ID Check Failed',
},
);
}
break;
}
}
reply.code(200);
return { received: true };
// never get here
}
@bindThis
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
const almostDefaultJsonParser: FastifyBodyParser<Buffer> = function (request, rawBody, done) {
if (rawBody.length === 0) {
const err = new Error('Body cannot be empty!') as any;
err.statusCode = 400;
return done(err);
}
try {
const json = secureJson.parse(rawBody.toString('utf8'), null, {
protoAction: 'ignore',
constructorAction: 'ignore',
});
done(null, json);
} catch (err: any) {
err.statusCode = 400;
return done(err);
}
};
fastify.register(cors, {
origin: '*',
});
fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, almostDefaultJsonParser);
fastify.addHook('onRequest', (request, reply, done) => {
reply.header('Cache-Control', 'private, max-age=0, must-revalidate');
done();
});
fastify.post<{
Body: any,
Headers: any,
}>('/hook', { config: { rawBody: true }, bodyLimit: 1024 * 64 }, async (request, reply) => await this.stripehook(request, reply));
done();
}
}