Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(server): support selfhost licenses #8947

Open
wants to merge 1 commit into
base: canary
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading