mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-19 11:06:25 +08:00
feat(server): support selfhost licenses (#8947)
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Param,
|
||||
Post,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaClient, Subscription } from '@prisma/client';
|
||||
import type { Response } from 'express';
|
||||
import Stripe from 'stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
CustomerPortalCreateFailed,
|
||||
InvalidLicenseToActivate,
|
||||
InvalidLicenseUpdateParams,
|
||||
LicenseNotFound,
|
||||
Mutex,
|
||||
} from '../../../base';
|
||||
import { Public } from '../../../core/auth';
|
||||
import { SelfhostTeamSubscriptionManager } from '../manager/selfhost';
|
||||
import { SubscriptionService } from '../service';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../types';
|
||||
|
||||
const UpdateSeatsParams = z.object({
|
||||
seats: z.number().min(1),
|
||||
});
|
||||
|
||||
const UpdateRecurringParams = z.object({
|
||||
recurring: z.enum([
|
||||
SubscriptionRecurring.Monthly,
|
||||
SubscriptionRecurring.Yearly,
|
||||
]),
|
||||
});
|
||||
|
||||
@Public()
|
||||
@Controller('/api/team/licenses')
|
||||
export class LicenseController {
|
||||
private readonly logger = new Logger(LicenseController.name);
|
||||
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly mutex: Mutex,
|
||||
private readonly subscription: SubscriptionService,
|
||||
private readonly manager: SelfhostTeamSubscriptionManager,
|
||||
private readonly stripe: Stripe
|
||||
) {}
|
||||
|
||||
@Post('/:license/activate')
|
||||
async activate(@Res() res: Response, @Param('license') key: string) {
|
||||
await using lock = await this.mutex.acquire(`license-activation:${key}`);
|
||||
|
||||
if (!lock) {
|
||||
throw new InvalidLicenseToActivate();
|
||||
}
|
||||
|
||||
const license = await this.db.license.findUnique({
|
||||
where: {
|
||||
key,
|
||||
},
|
||||
});
|
||||
|
||||
if (!license) {
|
||||
throw new InvalidLicenseToActivate();
|
||||
}
|
||||
|
||||
const subscription = await this.manager.getSubscription({
|
||||
key: license.key,
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
});
|
||||
|
||||
if (
|
||||
!subscription ||
|
||||
license.installedAt ||
|
||||
subscription.status !== SubscriptionStatus.Active
|
||||
) {
|
||||
throw new InvalidLicenseToActivate();
|
||||
}
|
||||
|
||||
const validateKey = randomUUID();
|
||||
await this.db.license.update({
|
||||
where: {
|
||||
key,
|
||||
},
|
||||
data: {
|
||||
installedAt: new Date(),
|
||||
validateKey,
|
||||
},
|
||||
});
|
||||
|
||||
res
|
||||
.status(HttpStatus.OK)
|
||||
.header('x-next-validate-key', validateKey)
|
||||
.json(this.license(subscription));
|
||||
}
|
||||
|
||||
@Post('/:license/deactivate')
|
||||
async deactivate(@Param('license') key: string) {
|
||||
await this.db.license.update({
|
||||
where: {
|
||||
key,
|
||||
},
|
||||
data: {
|
||||
installedAt: null,
|
||||
validateKey: null,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('/:license/health')
|
||||
async health(
|
||||
@Res() res: Response,
|
||||
@Param('license') key: string,
|
||||
@Headers('x-validate-key') revalidateKey: string
|
||||
) {
|
||||
const license = await this.db.license.findUnique({
|
||||
where: {
|
||||
key,
|
||||
},
|
||||
});
|
||||
|
||||
const subscription = await this.manager.getSubscription({
|
||||
key,
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
});
|
||||
|
||||
if (!license || !subscription) {
|
||||
throw new LicenseNotFound();
|
||||
}
|
||||
|
||||
if (license.validateKey && license.validateKey !== revalidateKey) {
|
||||
throw new InvalidLicenseToActivate();
|
||||
}
|
||||
|
||||
const validateKey = randomUUID();
|
||||
await this.db.license.update({
|
||||
where: {
|
||||
key,
|
||||
},
|
||||
data: {
|
||||
validateKey,
|
||||
},
|
||||
});
|
||||
|
||||
res
|
||||
.status(HttpStatus.OK)
|
||||
.header('x-next-validate-key', validateKey)
|
||||
.json(this.license(subscription));
|
||||
}
|
||||
|
||||
@Post('/:license/seats')
|
||||
async updateSeats(
|
||||
@Param('license') key: string,
|
||||
@Body() body: z.infer<typeof UpdateSeatsParams>
|
||||
) {
|
||||
const parseResult = UpdateSeatsParams.safeParse(body);
|
||||
|
||||
if (parseResult.error) {
|
||||
throw new InvalidLicenseUpdateParams({
|
||||
reason: parseResult.error.message,
|
||||
});
|
||||
}
|
||||
|
||||
const license = await this.db.license.findUnique({
|
||||
where: {
|
||||
key,
|
||||
},
|
||||
});
|
||||
|
||||
if (!license) {
|
||||
throw new LicenseNotFound();
|
||||
}
|
||||
|
||||
await this.subscription.updateSubscriptionQuantity(
|
||||
{
|
||||
key: license.key,
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
},
|
||||
parseResult.data.seats
|
||||
);
|
||||
}
|
||||
|
||||
@Post('/:license/recurring')
|
||||
async updateRecurring(
|
||||
@Param('license') key: string,
|
||||
@Body() body: z.infer<typeof UpdateRecurringParams>
|
||||
) {
|
||||
const parseResult = UpdateRecurringParams.safeParse(body);
|
||||
|
||||
if (parseResult.error) {
|
||||
throw new InvalidLicenseUpdateParams({
|
||||
reason: parseResult.error.message,
|
||||
});
|
||||
}
|
||||
|
||||
const license = await this.db.license.findUnique({
|
||||
where: {
|
||||
key,
|
||||
},
|
||||
});
|
||||
|
||||
if (!license) {
|
||||
throw new LicenseNotFound();
|
||||
}
|
||||
|
||||
await this.subscription.updateSubscriptionRecurring(
|
||||
{
|
||||
key: license.key,
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
},
|
||||
parseResult.data.recurring
|
||||
);
|
||||
}
|
||||
|
||||
@Post('/:license/create-customer-portal')
|
||||
async createCustomerPortal(@Param('license') key: string) {
|
||||
const invoice = await this.db.invoice.findFirst({
|
||||
where: {
|
||||
targetId: key,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new LicenseNotFound();
|
||||
}
|
||||
|
||||
const invoiceData = await this.stripe.invoices.retrieve(
|
||||
invoice.stripeInvoiceId,
|
||||
{
|
||||
expand: ['customer'],
|
||||
}
|
||||
);
|
||||
|
||||
const customer = invoiceData.customer as Stripe.Customer;
|
||||
try {
|
||||
const portal = await this.stripe.billingPortal.sessions.create({
|
||||
customer: customer.id,
|
||||
});
|
||||
|
||||
return { url: portal.url };
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to create customer portal.', e);
|
||||
throw new CustomerPortalCreateFailed();
|
||||
}
|
||||
}
|
||||
|
||||
license(subscription: Subscription) {
|
||||
return {
|
||||
plan: subscription.plan,
|
||||
recurring: subscription.recurring,
|
||||
quantity: subscription.quantity,
|
||||
endAt: subscription.end?.getTime(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user