mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 13:25:12 +00:00
feat(server): support installable license (#12181)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added support for installing self-hosted team licenses via encrypted license files. - Introduced a new "Onetime" license variant for self-hosted environments. - Added a GraphQL mutation to upload and install license files. - License details now display the license variant. - **Bug Fixes** - Improved error messages for license activation and expiration, including dynamic reasons. - **Localization** - Updated and improved license-related error messages for better clarity. - **Tests** - Added comprehensive end-to-end tests for license installation scenarios. - **Chores** - Enhanced environment variable handling and public key management for license verification. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -8,12 +8,15 @@ import {
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import GraphQLUpload, {
|
||||
type FileUpload,
|
||||
} from 'graphql-upload/GraphQLUpload.mjs';
|
||||
|
||||
import { UseNamedGuard } from '../../base';
|
||||
import { toBuffer, UseNamedGuard } from '../../base';
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { AccessController } from '../../core/permission';
|
||||
import { WorkspaceType } from '../../core/workspaces';
|
||||
import { SubscriptionRecurring } from '../payment/types';
|
||||
import { SubscriptionRecurring, SubscriptionVariant } from '../payment/types';
|
||||
import { LicenseService } from './service';
|
||||
|
||||
@ObjectType()
|
||||
@@ -24,6 +27,9 @@ export class License {
|
||||
@Field(() => SubscriptionRecurring)
|
||||
recurring!: string;
|
||||
|
||||
@Field(() => SubscriptionVariant, { nullable: true })
|
||||
variant!: string | null;
|
||||
|
||||
@Field(() => Date)
|
||||
installedAt!: Date;
|
||||
|
||||
@@ -82,7 +88,7 @@ export class LicenseResolver {
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Payment.Manage');
|
||||
|
||||
return this.service.deactivateTeamLicense(workspaceId);
|
||||
return this.service.removeTeamLicense(workspaceId);
|
||||
}
|
||||
|
||||
@Mutation(() => String)
|
||||
@@ -99,4 +105,22 @@ export class LicenseResolver {
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
@Mutation(() => License)
|
||||
async installLicense(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('license', { type: () => GraphQLUpload }) licenseFile: FileUpload
|
||||
) {
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Payment.Manage');
|
||||
|
||||
const buffer = await toBuffer(licenseFile.createReadStream());
|
||||
|
||||
const license = await this.service.installLicense(workspaceId, buffer);
|
||||
|
||||
return license;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { createDecipheriv, createVerify } from 'node:crypto';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InstalledLicense, PrismaClient } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
CryptoHelper,
|
||||
EventBus,
|
||||
InternalServerError,
|
||||
InvalidLicenseToActivate,
|
||||
LicenseExpired,
|
||||
LicenseNotFound,
|
||||
OnEvent,
|
||||
UserFriendlyError,
|
||||
WorkspaceLicenseAlreadyExists,
|
||||
} from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { SubscriptionPlan, SubscriptionRecurring } from '../payment/types';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionVariant,
|
||||
} from '../payment/types';
|
||||
|
||||
interface License {
|
||||
plan: SubscriptionPlan;
|
||||
@@ -20,6 +30,27 @@ interface License {
|
||||
endAt: number;
|
||||
}
|
||||
|
||||
const BaseLicenseSchema = z.object({
|
||||
entity: z.string().nonempty(),
|
||||
issuer: z.string().nonempty(),
|
||||
issuedAt: z.string().datetime(),
|
||||
expiresAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
const TeamLicenseSchema = z
|
||||
.object({
|
||||
subject: z.literal(SubscriptionPlan.SelfHostedTeam),
|
||||
data: z.object({
|
||||
id: z.string().nonempty(),
|
||||
workspaceId: z.string().nonempty(),
|
||||
plan: z.literal(SubscriptionPlan.SelfHostedTeam),
|
||||
recurring: z.nativeEnum(SubscriptionRecurring),
|
||||
quantity: z.number().positive(),
|
||||
endAt: z.string().datetime(),
|
||||
}),
|
||||
})
|
||||
.extend(BaseLicenseSchema.shape);
|
||||
|
||||
@Injectable()
|
||||
export class LicenseService {
|
||||
private readonly logger = new Logger(LicenseService.name);
|
||||
@@ -27,7 +58,8 @@ export class LicenseService {
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly event: EventBus,
|
||||
private readonly models: Models
|
||||
private readonly models: Models,
|
||||
private readonly crypto: CryptoHelper
|
||||
) {}
|
||||
|
||||
@OnEvent('workspace.subscription.activated')
|
||||
@@ -79,6 +111,7 @@ export class LicenseService {
|
||||
expiredAt: true,
|
||||
quantity: true,
|
||||
recurring: true,
|
||||
variant: true,
|
||||
},
|
||||
where: {
|
||||
workspaceId,
|
||||
@@ -86,6 +119,51 @@ export class LicenseService {
|
||||
});
|
||||
}
|
||||
|
||||
async installLicense(workspaceId: string, license: Buffer) {
|
||||
const payload = this.decryptWorkspaceTeamLicense(workspaceId, license);
|
||||
const data = payload.data;
|
||||
const now = new Date();
|
||||
|
||||
if (new Date(payload.expiresAt) < now || new Date(data.endAt) < now) {
|
||||
throw new LicenseExpired();
|
||||
}
|
||||
|
||||
const installed = await this.db.installedLicense.upsert({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
update: {
|
||||
key: data.id,
|
||||
expiredAt: new Date(data.endAt),
|
||||
validatedAt: new Date(),
|
||||
recurring: data.recurring,
|
||||
quantity: data.quantity,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
license,
|
||||
},
|
||||
create: {
|
||||
key: data.id,
|
||||
workspaceId,
|
||||
expiredAt: new Date(data.endAt),
|
||||
validateKey: '',
|
||||
validatedAt: new Date(),
|
||||
recurring: data.recurring,
|
||||
quantity: data.quantity,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
license,
|
||||
},
|
||||
});
|
||||
|
||||
await this.event.emitAsync('workspace.subscription.activated', {
|
||||
workspaceId,
|
||||
plan: data.plan,
|
||||
recurring: data.recurring,
|
||||
quantity: data.quantity,
|
||||
});
|
||||
|
||||
return installed;
|
||||
}
|
||||
|
||||
async activateTeamLicense(workspaceId: string, licenseKey: string) {
|
||||
const installedLicense = await this.getLicense(workspaceId);
|
||||
|
||||
@@ -132,7 +210,7 @@ export class LicenseService {
|
||||
return license;
|
||||
}
|
||||
|
||||
async deactivateTeamLicense(workspaceId: string) {
|
||||
async removeTeamLicense(workspaceId: string) {
|
||||
const license = await this.db.installedLicense.findUnique({
|
||||
where: {
|
||||
workspaceId,
|
||||
@@ -143,24 +221,31 @@ export class LicenseService {
|
||||
throw new LicenseNotFound();
|
||||
}
|
||||
|
||||
await this.fetchAffinePro(`/api/team/licenses/${license.key}/deactivate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
await this.db.installedLicense.deleteMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
workspaceId: license.workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (license.variant !== SubscriptionVariant.Onetime) {
|
||||
await this.deactivateTeamLicense(license);
|
||||
}
|
||||
|
||||
this.event.emit('workspace.subscription.canceled', {
|
||||
workspaceId,
|
||||
workspaceId: license.workspaceId,
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
recurring: license.recurring as SubscriptionRecurring,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async deactivateTeamLicense(license: InstalledLicense) {
|
||||
await this.fetchAffinePro(`/api/team/licenses/${license.key}/deactivate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async updateTeamRecurring(key: string, recurring: SubscriptionRecurring) {
|
||||
await this.fetchAffinePro(`/api/team/licenses/${key}/recurring`, {
|
||||
method: 'POST',
|
||||
@@ -222,7 +307,7 @@ export class LicenseService {
|
||||
let tried = 0;
|
||||
while (tried++ < 10) {
|
||||
try {
|
||||
const res = await this.revalidateLicense(license);
|
||||
const res = await this.revalidateRecurringLicense(license);
|
||||
|
||||
if (res?.quantity === memberRequired) {
|
||||
return;
|
||||
@@ -249,11 +334,15 @@ export class LicenseService {
|
||||
});
|
||||
|
||||
for (const license of licenses) {
|
||||
await this.revalidateLicense(license);
|
||||
if (license.variant === SubscriptionVariant.Onetime) {
|
||||
this.revalidateOnetimeLicense(license);
|
||||
} else {
|
||||
await this.revalidateRecurringLicense(license);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async revalidateLicense(license: InstalledLicense) {
|
||||
private async revalidateRecurringLicense(license: InstalledLicense) {
|
||||
try {
|
||||
const res = await this.fetchAffinePro<License>(
|
||||
`/api/team/licenses/${license.key}/health`,
|
||||
@@ -342,4 +431,132 @@ export class LicenseService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private revalidateOnetimeLicense(license: InstalledLicense) {
|
||||
const buf = license.license;
|
||||
let valid = !!buf;
|
||||
|
||||
if (buf) {
|
||||
try {
|
||||
const { data } = this.decryptWorkspaceTeamLicense(
|
||||
license.workspaceId,
|
||||
Buffer.from(buf)
|
||||
);
|
||||
|
||||
if (new Date(data.endAt) < new Date()) {
|
||||
valid = false;
|
||||
} else {
|
||||
this.event.emit('workspace.subscription.activated', {
|
||||
workspaceId: license.workspaceId,
|
||||
plan: data.plan,
|
||||
recurring: data.recurring,
|
||||
quantity: data.quantity,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
this.event.emit('workspace.subscription.canceled', {
|
||||
workspaceId: license.workspaceId,
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private decryptWorkspaceTeamLicense(workspaceId: string, buf: Buffer) {
|
||||
if (!this.crypto.AFFiNEProPublicKey) {
|
||||
throw new InternalServerError(
|
||||
'License public key is not loaded. Please contact with Affine support.'
|
||||
);
|
||||
}
|
||||
|
||||
// we use workspace id as aes key hash plain text content
|
||||
// verify signature to make sure the payload or signature is not forged
|
||||
const {
|
||||
payload: payloadStr,
|
||||
signature,
|
||||
iv,
|
||||
} = this.decryptLicense(workspaceId, buf);
|
||||
|
||||
const verifier = createVerify('rsa-sha256');
|
||||
verifier.update(iv);
|
||||
verifier.update(payloadStr);
|
||||
const valid = verifier.verify(
|
||||
this.crypto.AFFiNEProPublicKey,
|
||||
signature,
|
||||
'hex'
|
||||
);
|
||||
if (!valid) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Invalid license signature.',
|
||||
});
|
||||
}
|
||||
|
||||
const payload = JSON.parse(payloadStr);
|
||||
|
||||
const parseResult = TeamLicenseSchema.safeParse(payload);
|
||||
|
||||
if (!parseResult.success) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Invalid license payload.',
|
||||
});
|
||||
}
|
||||
|
||||
if (parseResult.data.data.workspaceId !== workspaceId) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Workspace mismatched with license.',
|
||||
});
|
||||
}
|
||||
|
||||
return parseResult.data;
|
||||
}
|
||||
|
||||
private decryptLicense(workspaceId: string, buf: Buffer) {
|
||||
if (buf.length < 2) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Invalid license file.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const ivLength = buf.readUint8(0);
|
||||
const authTagLength = buf.readUInt8(1);
|
||||
|
||||
const iv = buf.subarray(2, 2 + ivLength);
|
||||
const tag = buf.subarray(2 + ivLength, 2 + ivLength + authTagLength);
|
||||
const payload = buf.subarray(2 + ivLength + authTagLength);
|
||||
|
||||
const aesKey = this.crypto.sha256(
|
||||
`WORKSPACE_PAYLOAD_AES_KEY:${workspaceId}`
|
||||
);
|
||||
const decipher = createDecipheriv('aes-256-gcm', aesKey, iv, {
|
||||
authTagLength,
|
||||
});
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(payload),
|
||||
decipher.final(),
|
||||
]);
|
||||
|
||||
const data = JSON.parse(decrypted.toString('utf-8')) as {
|
||||
payload: string;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
return {
|
||||
...data,
|
||||
iv,
|
||||
};
|
||||
} catch {
|
||||
// we use workspace id as aes key hash plain text content
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Workspace mismatched with license.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,9 @@ export class LicenseController {
|
||||
await using lock = await this.mutex.acquire(`license-activation:${key}`);
|
||||
|
||||
if (!lock) {
|
||||
throw new InvalidLicenseToActivate();
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Too Many Requests',
|
||||
});
|
||||
}
|
||||
|
||||
const license = await this.db.license.findUnique({
|
||||
@@ -72,7 +74,9 @@ export class LicenseController {
|
||||
});
|
||||
|
||||
if (!license) {
|
||||
throw new InvalidLicenseToActivate();
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'License not found',
|
||||
});
|
||||
}
|
||||
|
||||
const subscription = await this.manager.getSubscription({
|
||||
@@ -85,7 +89,9 @@ export class LicenseController {
|
||||
license.installedAt ||
|
||||
subscription.status !== SubscriptionStatus.Active
|
||||
) {
|
||||
throw new InvalidLicenseToActivate();
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Invalid license',
|
||||
});
|
||||
}
|
||||
|
||||
const validateKey = randomUUID();
|
||||
@@ -144,7 +150,9 @@ export class LicenseController {
|
||||
}
|
||||
|
||||
if (license.validateKey && license.validateKey !== revalidateKey) {
|
||||
throw new InvalidLicenseToActivate();
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Invalid validate key',
|
||||
});
|
||||
}
|
||||
|
||||
const validateKey = randomUUID();
|
||||
|
||||
Reference in New Issue
Block a user