201 lines
5.5 KiB
TypeScript
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();
|
|
}
|
|
}
|