Skip to content

Commit

Permalink
feat(server): support selfhost licenses
Browse files Browse the repository at this point in the history
  • Loading branch information
forehalo committed Dec 13, 2024
1 parent 055abf6 commit 959abb4
Show file tree
Hide file tree
Showing 23 changed files with 1,314 additions and 124 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "licenses" (
"key" VARCHAR NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revealed_at" TIMESTAMPTZ(3),
"installed_at" TIMESTAMPTZ(3),
"validate_key" VARCHAR,

CONSTRAINT "licenses_pkey" PRIMARY KEY ("key")
);

-- CreateTable
CREATE TABLE "installed_licenses" (
"key" VARCHAR NOT NULL,
"workspace_id" VARCHAR NOT NULL,
"installed_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"validate_key" VARCHAR,
"validated_at" TIMESTAMPTZ(3),

CONSTRAINT "installed_licenses_pkey" PRIMARY KEY ("key")
);

-- CreateIndex
CREATE UNIQUE INDEX "installed_licenses_workspace_id_key" ON "installed_licenses"("workspace_id");
31 changes: 26 additions & 5 deletions packages/backend/server/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -569,15 +569,36 @@ model Invoice {
@@index([targetId])
@@map("invoices")
}

model License {
key String @id @map("key") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
revealedAt DateTime? @map("revealed_at") @db.Timestamptz(3)
installedAt DateTime? @map("installed_at") @db.Timestamptz(3)
validateKey String? @map("validate_key") @db.VarChar
@@map("licenses")
}

model InstalledLicense {
key String @id @map("key") @db.VarChar
workspaceId String @unique @map("workspace_id") @db.VarChar
installedAt DateTime @default(now()) @map("installed_at") @db.Timestamptz(3)
validateKey String? @map("validate_key") @db.VarChar
validatedAt DateTime? @map("validated_at") @db.Timestamptz(3)
@@map("installed_licenses")
}

// Blob table only exists for fast non-data queries.
// like, total size of blobs in a workspace, or blob list for sync service.
// it should only be a map of metadata of blobs stored anywhere else
model Blob {
workspaceId String @map("workspace_id") @db.VarChar
key String @db.VarChar
size Int @db.Integer
mime String @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
workspaceId String @map("workspace_id") @db.VarChar
key String @db.VarChar
size Int @db.Integer
mime String @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { SyncModule } from './core/sync';
import { UserModule } from './core/user';
import { WorkspaceModule } from './core/workspaces';
import { REGISTERED_PLUGINS } from './plugins';
import { LicenseModule } from './plugins/license';
import { ENABLED_PLUGINS } from './plugins/registry';

export const FunctionalityModules = [
Expand Down Expand Up @@ -174,7 +175,7 @@ export function buildAppModule() {
)

// self hosted server only
.useIf(config => config.isSelfhosted, SelfhostModule)
.useIf(config => config.isSelfhosted, SelfhostModule, LicenseModule)
.useIf(config => config.flavor.renderer, DocRendererModule);

// plugin modules
Expand Down
34 changes: 34 additions & 0 deletions packages/backend/server/src/base/error/def.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,4 +592,38 @@ export const USER_FRIENDLY_ERRORS = {
type: 'bad_request',
message: 'Captcha verification failed.',
},

// license errors
invalid_license_session_id: {
type: 'invalid_input',
message: 'Invalid session id to generate license key.',
},
license_revealed: {
type: 'action_forbidden',
message:
'License key has been revealed. Please check your mail box of the one provided during checkout.',
},
workspace_license_already_exists: {
type: 'action_forbidden',
message: 'Workspace already has a license applied.',
},
license_not_found: {
type: 'resource_not_found',
message: 'License not found.',
},
invalid_license_to_activate: {
type: 'bad_request',
message: 'Invalid license to activate.',
},
invalid_license_update_params: {
type: 'invalid_input',
args: { reason: 'string' },
message: ({ reason }) => `Invalid license update params. ${reason}`,
},
workspace_members_exceed_limit_to_downgrade: {
type: 'bad_request',
args: { limit: 'number' },
message: ({ limit }) =>
`You cannot downgrade the workspace from team workspace because there are more than ${limit} members that are currently active.`,
},
} satisfies Record<string, UserFriendlyErrorOptions>;
61 changes: 59 additions & 2 deletions packages/backend/server/src/base/error/errors.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,56 @@ export class CaptchaVerificationFailed extends UserFriendlyError {
super('bad_request', 'captcha_verification_failed', message);
}
}

export class InvalidLicenseSessionId extends UserFriendlyError {
constructor(message?: string) {
super('invalid_input', 'invalid_license_session_id', message);
}
}

export class LicenseRevealed extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'license_revealed', message);
}
}

export class WorkspaceLicenseAlreadyExists extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'workspace_license_already_exists', message);
}
}

export class LicenseNotFound extends UserFriendlyError {
constructor(message?: string) {
super('resource_not_found', 'license_not_found', message);
}
}

export class InvalidLicenseToActivate extends UserFriendlyError {
constructor(message?: string) {
super('bad_request', 'invalid_license_to_activate', message);
}
}
@ObjectType()
class InvalidLicenseUpdateParamsDataType {
@Field() reason!: string
}

export class InvalidLicenseUpdateParams extends UserFriendlyError {
constructor(args: InvalidLicenseUpdateParamsDataType, message?: string | ((args: InvalidLicenseUpdateParamsDataType) => string)) {
super('invalid_input', 'invalid_license_update_params', message, args);
}
}
@ObjectType()
class WorkspaceMembersExceedLimitToDowngradeDataType {
@Field() limit!: number
}

export class WorkspaceMembersExceedLimitToDowngrade extends UserFriendlyError {
constructor(args: WorkspaceMembersExceedLimitToDowngradeDataType, message?: string | ((args: WorkspaceMembersExceedLimitToDowngradeDataType) => string)) {
super('bad_request', 'workspace_members_exceed_limit_to_downgrade', message, args);
}
}
export enum ErrorNames {
INTERNAL_SERVER_ERROR,
TOO_MANY_REQUEST,
Expand Down Expand Up @@ -665,7 +715,14 @@ export enum ErrorNames {
MAILER_SERVICE_IS_NOT_CONFIGURED,
CANNOT_DELETE_ALL_ADMIN_ACCOUNT,
CANNOT_DELETE_OWN_ACCOUNT,
CAPTCHA_VERIFICATION_FAILED
CAPTCHA_VERIFICATION_FAILED,
INVALID_LICENSE_SESSION_ID,
LICENSE_REVEALED,
WORKSPACE_LICENSE_ALREADY_EXISTS,
LICENSE_NOT_FOUND,
INVALID_LICENSE_TO_ACTIVATE,
INVALID_LICENSE_UPDATE_PARAMS,
WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE
}
registerEnumType(ErrorNames, {
name: 'ErrorNames'
Expand All @@ -674,5 +731,5 @@ registerEnumType(ErrorNames, {
export const ErrorDataUnionType = createUnionType({
name: 'ErrorDataUnion',
types: () =>
[WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, SpaceNotFoundDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, DocNotFoundDataType, DocAccessDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType] as const,
[WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, SpaceNotFoundDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, DocNotFoundDataType, DocAccessDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseUpdateParamsDataType, WorkspaceMembersExceedLimitToDowngradeDataType] as const,
});
18 changes: 18 additions & 0 deletions packages/backend/server/src/base/helpers/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,24 @@ export class URLHelper {
return new URLSearchParams(query).toString();
}

addSimpleQuery(
url: string,
key: string,
value: string | number | boolean,
escape = true
) {
const urlObj = new URL(url);
if (escape) {
urlObj.searchParams.set(key, encodeURIComponent(value));
return urlObj.toString();
} else {
const query =
(urlObj.search ? urlObj.search + '&' : '?') + `${key}=${value}`;

return urlObj.origin + urlObj.pathname + query;
}
}

url(path: string, query: Record<string, any> = {}) {
const url = new URL(path, this.origin);

Expand Down
9 changes: 9 additions & 0 deletions packages/backend/server/src/base/mailer/mail.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,4 +311,13 @@ export class MailService {
});
return this.sendMail({ to, subject: title, html });
}

async sendLicenseGeneratedEmail(to: string, licenseKey: string) {
const html = emailTemplate({
title: 'Your license key for AFFiNE self-hosted workspace',
content: `Your license key is: <br><b>${licenseKey}</b>`,
});

return this.sendMail({ to, subject: 'Your AFFiNE license key', html });
}
}
60 changes: 2 additions & 58 deletions packages/backend/server/src/core/quota/service.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

import type { EventPayload } from '../../base';
import { OnEvent, PrismaTransaction } from '../../base';
import { FeatureManagementService } from '../features/management';
import { PrismaTransaction } from '../../base';
import { FeatureKind } from '../features/types';
import { QuotaConfig } from './quota';
import { QuotaType } from './types';

@Injectable()
export class QuotaService {
constructor(
private readonly prisma: PrismaClient,
private readonly feature: FeatureManagementService
) {}
constructor(private readonly prisma: PrismaClient) {}

async getQuota<Q extends QuotaType>(
quota: Q,
Expand Down Expand Up @@ -314,55 +309,4 @@ export class QuotaService {
});
return r.count;
}

@OnEvent('user.subscription.activated')
async onSubscriptionUpdated({
userId,
plan,
recurring,
}: EventPayload<'user.subscription.activated'>) {
switch (plan) {
case 'ai':
await this.feature.addCopilot(userId, 'subscription activated');
break;
case 'pro':
await this.switchUserQuota(
userId,
recurring === 'lifetime'
? QuotaType.LifetimeProPlanV1
: QuotaType.ProPlanV1,
'subscription activated'
);
break;
default:
break;
}
}

@OnEvent('user.subscription.canceled')
async onSubscriptionCanceled({
userId,
plan,
}: EventPayload<'user.subscription.canceled'>) {
switch (plan) {
case 'ai':
await this.feature.removeCopilot(userId);
break;
case 'pro': {
// edge case: when user switch from recurring Pro plan to `Lifetime` plan,
// a subscription canceled event will be triggered because `Lifetime` plan is not subscription based
const quota = await this.getUserQuota(userId);
if (quota.feature.name !== QuotaType.LifetimeProPlanV1) {
await this.switchUserQuota(
userId,
QuotaType.FreePlanV1,
'subscription canceled'
);
}
break;
}
default:
break;
}
}
}
11 changes: 11 additions & 0 deletions packages/backend/server/src/plugins/license/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { OptionalModule } from '../../base';
import { PermissionModule } from '../../core/permission';
import { QuotaModule } from '../../core/quota';
import { LicenseService } from './service';

@OptionalModule({
if: config => config.isSelfhosted,
imports: [QuotaModule, PermissionModule],
providers: [LicenseService],
})
export class LicenseModule {}
Loading

0 comments on commit 959abb4

Please sign in to comment.