chore: cleanup legacy logic (#15072)

This commit is contained in:
DarkSky
2026-06-03 16:20:15 +08:00
committed by GitHub
parent 8c0e1ba04e
commit 81760fd45c
142 changed files with 3351 additions and 2449 deletions
@@ -5,9 +5,9 @@ import { XMLParser } from 'fast-xml-parser';
import { escape } from 'lodash-es';
import {
assertSsrFSafeUrl,
CalendarProviderRequestError,
GraphqlBadRequest,
safeFetch,
SsrfBlockedError,
} from '../../../base';
import type {
@@ -35,6 +35,8 @@ const XML_PARSER = new XMLParser({
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_REDIRECTS = 5;
const DEFAULT_MAX_RESPONSE_BYTES = 50 * 1024 * 1024;
const CALDAV_SAFE_FETCH_HEADERS = ['authorization', 'content-type', 'depth'];
type CalDAVCredentials = {
username: string;
@@ -106,9 +108,6 @@ const formatUtcForIcal = (iso: string) => {
].join('');
};
const isRedirectStatus = (status: number) =>
[301, 302, 303, 307, 308].includes(status);
const splitHeaderTokens = (value: string) =>
value
.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)
@@ -536,36 +535,24 @@ class CalDAVRequestPolicy {
return this.config.calendar.caldav.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
}
async fetch(
url: string,
init: RequestInit,
redirects = 0
): Promise<Response> {
async fetch(url: string, init: RequestInit): Promise<Response> {
await this.assertAllowedUrl(url);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
const response = await fetch(url, {
...init,
redirect: 'manual',
signal: controller.signal,
}).finally(() => clearTimeout(timer));
if (isRedirectStatus(response.status)) {
const location = response.headers.get('location');
if (location) {
if (redirects >= this.maxRedirects) {
throw new GraphqlBadRequest({
code: 'caldav_max_redirects',
message: 'CalDAV request exceeded redirect limit.',
});
}
const nextUrl = resolveHref(location, url);
return this.fetch(nextUrl, init, redirects + 1);
}
try {
return await safeFetch(url, init, {
timeoutMs: this.timeoutMs,
maxRedirects: this.maxRedirects,
maxBytes: DEFAULT_MAX_RESPONSE_BYTES,
allowedHeaders: CALDAV_SAFE_FETCH_HEADERS,
allowedHosts: this.allowedHosts,
allowHttp: this.allowInsecureHttp,
allowPrivateTargetOrigin: !this.blockPrivateNetwork,
});
} catch (error) {
const ssrfError = this.toGraphqlSsrfError(error);
if (ssrfError) throw ssrfError;
throw error;
}
return response;
}
private async assertAllowedUrl(urlValue: string) {
@@ -599,52 +586,62 @@ class CalDAVRequestPolicy {
message: 'CalDAV host is not allowed.',
});
}
}
if (!this.blockPrivateNetwork) {
return;
private toGraphqlSsrfError(error: unknown) {
if (!(error instanceof SsrfBlockedError)) {
return null;
}
try {
await assertSsrFSafeUrl(url);
} catch (error) {
if (error instanceof SsrfBlockedError) {
const reason = String(error.data?.reason ?? '');
const reason = String(error.data?.reason ?? '');
if (reason === 'blocked_ip') {
throw new GraphqlBadRequest({
code: 'caldav_private_network',
message: 'CalDAV host is in a private network.',
});
}
if (reason === 'unresolvable_hostname') {
throw new GraphqlBadRequest({
code: 'caldav_dns_failed',
message: 'Unable to resolve CalDAV host.',
});
}
if (reason === 'disallowed_protocol') {
throw new GraphqlBadRequest({
code: 'caldav_insecure_url',
message: 'CalDAV URL must use https.',
});
}
if (
reason === 'invalid_url' ||
reason === 'blocked_hostname' ||
reason === 'url_has_credentials'
) {
throw new GraphqlBadRequest({
code: 'caldav_invalid_url',
message: 'CalDAV URL is invalid.',
});
}
}
throw error;
if (reason === 'blocked_ip') {
return new GraphqlBadRequest({
code: 'caldav_private_network',
message: 'CalDAV host is in a private network.',
});
}
if (reason === 'unresolvable_hostname') {
return new GraphqlBadRequest({
code: 'caldav_dns_failed',
message: 'Unable to resolve CalDAV host.',
});
}
if (reason === 'disallowed_protocol') {
return new GraphqlBadRequest({
code: 'caldav_insecure_url',
message: 'CalDAV URL must use https.',
});
}
if (reason === 'too_many_redirects') {
return new GraphqlBadRequest({
code: 'caldav_max_redirects',
message: 'CalDAV request exceeded redirect limit.',
});
}
if (reason === 'host_not_allowed') {
return new GraphqlBadRequest({
code: 'caldav_host_blocked',
message: 'CalDAV host is not allowed.',
});
}
if (
reason === 'invalid_url' ||
reason === 'blocked_hostname' ||
reason === 'url_has_credentials'
) {
return new GraphqlBadRequest({
code: 'caldav_invalid_url',
message: 'CalDAV URL is invalid.',
});
}
return null;
}
}
@@ -2,7 +2,13 @@ import { createHash, createHmac, randomUUID } from 'node:crypto';
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequest, Cache, CryptoHelper, metrics } from '../../../base';
import {
BadRequest,
Cache,
CryptoHelper,
metrics,
safeFetch,
} from '../../../base';
import { Models } from '../../../models';
import type { CopilotProviderProfile } from '../config';
import { ByokEntitlementPolicy } from './policy';
@@ -103,6 +109,8 @@ type ByokProfileMeta = {
@Injectable()
export class ByokService {
private readonly probeFetch = safeFetch;
constructor(
private readonly models: Models,
private readonly crypto: CryptoHelper,
@@ -755,22 +763,24 @@ export class ByokService {
apiKey: string,
endpoint: string | null
) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
try {
const request = this.buildProbeRequest(provider, apiKey, endpoint);
const response = await fetch(request.url, {
const request = this.buildProbeRequest(provider, apiKey, endpoint);
const response = await this.probeFetch(
request.url,
{
method: request.method,
headers: request.headers as unknown as Record<string, string>,
signal: controller.signal,
});
if (!response.ok) {
throw new BadRequestException(
this.providerProbeFailureMessage(response.status)
);
},
{
timeoutMs: TEST_TIMEOUT_MS,
maxRedirects: 3,
maxBytes: 1024,
allowedHeaders: Object.keys(request.headers),
}
} finally {
clearTimeout(timeout);
);
if (!response.ok) {
throw new BadRequestException(
this.providerProbeFailureMessage(response.status)
);
}
}
@@ -66,12 +66,12 @@ export class CopilotEmbeddingRealtimeProvider implements OnModuleInit {
@OnEvent('workspace.file.embed.finished', { suppressError: true })
async onFileEmbedFinished(payload: Events['workspace.file.embed.finished']) {
await this.publishContext(payload.contextId, 'finished');
await this.publishEmbeddingProgress(payload, 'finished');
}
@OnEvent('workspace.file.embed.failed', { suppressError: true })
async onFileEmbedFailed(payload: Events['workspace.file.embed.failed']) {
await this.publishContext(payload.contextId, 'failed');
await this.publishEmbeddingProgress(payload, 'failed');
}
@OnEvent('workspace.blob.embed.finished', { suppressError: true })
@@ -90,11 +90,29 @@ export class CopilotEmbeddingRealtimeProvider implements OnModuleInit {
) {
if (!this.publisher) return;
const context = await this.context.get(contextId);
this.publishWorkspace(context.workspaceId, reason);
}
private async publishEmbeddingProgress(
payload:
| Events['workspace.file.embed.finished']
| Events['workspace.file.embed.failed'],
reason: 'finished' | 'failed'
) {
if (!this.publisher) return;
if (payload.contextId) {
await this.publishContext(payload.contextId, reason);
return;
}
this.publishWorkspace(payload.workspaceId, reason);
}
private publishWorkspace(workspaceId: string, reason: 'finished' | 'failed') {
this.publisher.publish(
'workspace.embedding.progress.changed',
{ workspaceId: context.workspaceId },
{ workspaceId },
{ reason },
{ room: workspaceEmbeddingRoom(context.workspaceId) }
{ room: workspaceEmbeddingRoom(workspaceId) }
);
}
@@ -354,6 +354,7 @@ export class CopilotContextService implements OnApplicationBootstrap {
fileId,
chunkSize,
}: Events['workspace.file.embed.finished']) {
if (!contextId) return;
const context = await this.get(contextId);
await context.saveFileRecord(fileId, file => ({
...(file as ContextFile),
@@ -368,6 +369,7 @@ export class CopilotContextService implements OnApplicationBootstrap {
fileId,
error,
}: Events['workspace.file.embed.failed']) {
if (!contextId) return;
const context = await this.get(contextId);
await context.saveFileRecord(fileId, file => ({
...(file as ContextFile),
@@ -300,21 +300,19 @@ export class CopilotEmbeddingJob {
}
}
if (contextId) {
this.event.emit('workspace.file.embed.finished', {
contextId,
fileId,
chunkSize: total,
});
}
this.event.emit('workspace.file.embed.finished', {
contextId,
workspaceId,
fileId,
chunkSize: total,
});
} catch (error: any) {
if (contextId) {
this.event.emit('workspace.file.embed.failed', {
contextId,
fileId,
error: mapAnyError(error).message,
});
}
this.event.emit('workspace.file.embed.failed', {
contextId,
workspaceId,
fileId,
error: mapAnyError(error).message,
});
// passthrough error to job queue
throw error;
@@ -43,13 +43,15 @@ declare global {
};
'workspace.file.embed.finished': {
contextId: string;
contextId?: string;
workspaceId: string;
fileId: string;
chunkSize: number;
};
'workspace.file.embed.failed': {
contextId: string;
contextId?: string;
workspaceId: string;
fileId: string;
error: string;
};
@@ -1,22 +0,0 @@
import { defineModuleConfig } from '../../base';
declare global {
interface AppConfigSchema {
customerIo: {
enabled: boolean;
token: string;
};
}
}
defineModuleConfig('customerIo', {
enabled: {
desc: 'Enable customer.io integration',
default: false,
},
token: {
desc: 'Customer.io token',
default: '',
schema: { type: 'string' },
},
});
@@ -1,10 +0,0 @@
import './config';
import { Module } from '@nestjs/common';
import { CustomerIoService } from './service';
@Module({
providers: [CustomerIoService],
})
export class CustomerIoModule {}
@@ -1,72 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Config, OnEvent } from '../../base';
@Injectable()
export class CustomerIoService {
#fetch: ((url: string, options?: RequestInit) => Promise<Response>) | null =
null;
constructor(private readonly config: Config) {}
@OnEvent('config.init')
setup() {
const { enabled, token } = this.config.customerIo;
if (enabled && token) {
this.#fetch = (url, options) => {
return fetch(url, {
...options,
headers: {
Authorization: `Basic ${token}`,
'Content-Type': 'application/json',
...options?.headers,
},
});
};
} else {
this.#fetch = null;
}
}
@OnEvent('config.changed')
onConfigChanged(event: Events['config.changed']) {
if (event.updates.customerIo) {
this.setup();
}
}
@OnEvent('user.created')
@OnEvent('user.updated')
async onUserUpdated(user: Events['user.updated'] | Events['user.created']) {
await this.#fetch?.(
`https://track.customer.io/api/v1/customers/${user.id}`,
{
method: 'PUT',
body: JSON.stringify({
name: user.name,
email: user.email,
created_at: Number(user.createdAt) / 1000,
}),
}
);
}
@OnEvent('user.deleted')
async onUserDeleted(user: Events['user.deleted']) {
if (user.emailVerifiedAt) {
// suppress email if email is verified
await this.#fetch?.(
`https://track.customer.io/api/v1/customers/${user.email}/suppress`,
{
method: 'POST',
}
);
}
await this.#fetch?.(
`https://track.customer.io/api/v1/customers/${user.id}`,
{
method: 'DELETE',
}
);
}
}
@@ -1803,6 +1803,41 @@ test('should parse es query with custom term mapping field work', async t => {
t.snapshot(result3);
});
test('should parse es query with parent and nested must_not work', async t => {
const nestedMust = {
must: [
{ term: { workspace_id: 'workspaceId1' } },
{ bool: { must_not: { term: { doc_id: 'docId1' } } } },
],
};
const parentMustNot = { term: { doc_id: 'docId2' } };
const expectedMust = [{ equals: { workspace_id: 'workspaceId1' } }];
const expectedMustNot = ['docId1', 'docId2'];
const queryWithParentMustNotFirst = {
bool: { must_not: parentMustNot, must: nestedMust.must },
};
const queryWithParentMustNotLast = {
bool: { must: nestedMust.must, must_not: parentMustNot },
};
// @ts-expect-error use private method
const result = searchProvider.parseESQuery(queryWithParentMustNotFirst);
// @ts-expect-error use private method
const result2 = searchProvider.parseESQuery(queryWithParentMustNotLast);
t.deepEqual(result.bool.must, expectedMust);
t.deepEqual(result2.bool.must, expectedMust);
t.deepEqual(
result.bool.must_not.map((clause: any) => clause.equals.doc_id).sort(),
expectedMustNot
);
t.deepEqual(
result2.bool.must_not.map((clause: any) => clause.equals.doc_id).sort(),
expectedMustNot
);
});
test('should parse es query exists work', async t => {
const query = {
exists: {
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import {
InternalServerError,
InvalidSearchProviderRequest,
safeFetch,
} from '../../../base';
import { SearchProviderType } from '../config';
import { DateFieldNames, SearchTable, SearchTableUniqueId } from '../tables';
@@ -61,6 +62,14 @@ interface ESAggregateResponse extends ESSearchResponse {
};
}
const INDEXER_FETCH_OPTIONS = {
timeoutMs: 30_000,
maxRedirects: 0,
maxBytes: 50 * 1024 * 1024,
allowedHeaders: ['authorization', 'content-type'],
allowPrivateTargetOrigin: true,
};
@Injectable()
export class ElasticsearchProvider extends SearchProvider {
type = SearchProviderType.Elasticsearch;
@@ -278,11 +287,11 @@ export class ElasticsearchProvider extends SearchProvider {
} else if (this.config.provider.password) {
headers.Authorization = `Basic ${Buffer.from(`${this.config.provider.username}:${this.config.provider.password}`).toString('base64')}`;
}
const response = await fetch(url, {
method,
body,
headers,
});
const response = await safeFetch(
url,
{ method, body, headers },
INDEXER_FETCH_OPTIONS
);
const data = await response.json();
if (ignoreErrorStatus?.includes(response.status)) {
return data;
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { omit } from 'lodash-es';
import { InternalServerError } from '../../../base';
import { InternalServerError, safeFetch } from '../../../base';
import { SearchProviderType } from '../config';
import { SearchTable } from '../tables';
import {
@@ -32,6 +32,14 @@ interface MSSearchResponse {
scroll: string;
}
const INDEXER_FETCH_OPTIONS = {
timeoutMs: 30_000,
maxRedirects: 0,
maxBytes: 50 * 1024 * 1024,
allowedHeaders: ['authorization', 'content-type'],
allowPrivateTargetOrigin: true,
};
const SupportIndexedAttributes = [
'flavour',
'parent_flavour',
@@ -58,6 +66,11 @@ const ConvertEmptyStringToNullValueFields = new Set([
'parent_flavour',
]);
function boolClauses(value: unknown) {
if (value === undefined) return [];
return Array.isArray(value) ? value : [value];
}
@Injectable()
export class ManticoresearchProvider extends ElasticsearchProvider {
override type = SearchProviderType.Manticoresearch;
@@ -282,7 +295,9 @@ export class ManticoresearchProvider extends ElasticsearchProvider {
for (const occur in query.bool) {
const conditions = query.bool[occur];
if (Array.isArray(conditions)) {
node.bool[occur] = [];
const parsedConditions: Record<string, any>[] = [];
const existing = node.bool[occur];
node.bool[occur] = parsedConditions;
// { must: [ { term: [Object] }, { bool: [Object] } ] }
// {
// must: [ { term: [Object] }, { term: [Object] }, { bool: [Object] } ]
@@ -290,16 +305,41 @@ export class ManticoresearchProvider extends ElasticsearchProvider {
for (const item of conditions) {
this.parseESQuery(item, {
...options,
parentNodes: node.bool[occur],
parentNodes: parsedConditions,
});
}
if (existing !== undefined) {
node.bool[occur] = [...boolClauses(existing), ...parsedConditions];
}
if (occur === 'must') {
for (let i = node.bool.must.length - 1; i >= 0; i--) {
const child = node.bool.must[i];
const childBool = child.bool;
if (
childBool &&
Object.keys(childBool).length === 1 &&
childBool.must_not !== undefined
) {
node.bool.must.splice(i, 1);
node.bool.must_not = [
...boolClauses(node.bool.must_not),
...boolClauses(childBool.must_not),
];
}
}
}
} else {
// {
// must_not: { term: { doc_id: 'docId' } }
// }
node.bool[occur] = this.parseESQuery(conditions, {
const parsed = this.parseESQuery(conditions, {
termMappingField: options?.termMappingField,
});
if (node.bool[occur] !== undefined) {
node.bool[occur] = [...boolClauses(node.bool[occur]), parsed];
} else {
node.bool[occur] = parsed;
}
}
}
} else if (query.term) {
@@ -460,11 +500,11 @@ export class ManticoresearchProvider extends ElasticsearchProvider {
headers.Authorization = `Basic ${Buffer.from(`${this.config.provider.username}:${this.config.provider.password}`).toString('base64')}`;
}
const response = await fetch(url, {
method: 'POST',
body: sql,
headers,
});
const response = await safeFetch(
url,
{ method: 'POST', body: sql, headers },
INDEXER_FETCH_OPTIONS
);
const text = (await response.text()).trim();
if (!response.ok) {
this.logger.error(`failed to execute SQL "${sql}", response: ${text}`);
@@ -17,21 +17,34 @@ import { EntitlementService } from '../../core/entitlement';
import { WorkspacePolicyService } from '../../core/permission';
import { QuotaStateService } from '../../core/quota/state';
import { Models } from '../../models';
import { ResolvedEntitlement, resolveEntitlementV1 } from '../../native';
import {
activateLicense,
checkLicenseHealth,
type CommandResponse,
createCustomerPortal,
deactivateLicense,
type LicenseError,
type LicenseResponse,
type PortalResponse,
type ResolvedEntitlement,
resolveEntitlementV1,
updateLicenseRecurring,
updateLicenseSeats,
} from '../../native';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionVariant,
} from '../payment/types';
interface License {
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
quantity: number;
endAt: number;
}
const AFFINE_PRO_REQUEST_TIMEOUT = 10_000;
export const licenseClient = {
activate: activateLicense,
checkHealth: checkLicenseHealth,
createCustomerPortal,
deactivate: deactivateLicense,
updateRecurring: updateLicenseRecurring,
updateSeats: updateLicenseSeats,
};
export interface LicensePreview {
id: string;
@@ -157,31 +170,27 @@ export class LicenseService {
throw new WorkspaceLicenseAlreadyExists();
}
const data = await this.fetchAffinePro<License>(
`/api/team/licenses/${licenseKey}/activate`,
{
method: 'POST',
}
const data = this.remoteLicense(
await licenseClient.activate({ licenseKey })
);
const validatedAt = new Date();
const expiresAt = this.remoteLicenseExpiresAt(data);
const validateKey = data.res.headers.get('x-next-validate-key') ?? '';
const expiresAt = new Date(data.expiresAt);
this.event.emit('workspace.subscription.activated', {
workspaceId,
plan: data.plan,
recurring: data.recurring,
plan: data.plan as SubscriptionPlan,
recurring: data.recurring as SubscriptionRecurring,
quantity: data.quantity,
});
await this.entitlement.upsertFromValidatedSelfhostLicense({
workspaceId,
licenseKey,
recurring: data.recurring,
recurring: data.recurring as SubscriptionRecurring,
quantity: data.quantity,
expiresAt,
validatedAt,
validateKey,
validateKey: data.validateKey,
});
return this.db.installedLicense.upsert({
@@ -189,9 +198,9 @@ export class LicenseService {
update: {
key: licenseKey,
quantity: data.quantity,
recurring: data.recurring,
recurring: data.recurring as SubscriptionRecurring,
variant: null,
validateKey,
validateKey: data.validateKey,
validatedAt,
expiredAt: expiresAt,
license: null,
@@ -200,9 +209,9 @@ export class LicenseService {
workspaceId,
key: licenseKey,
quantity: data.quantity,
recurring: data.recurring,
recurring: data.recurring as SubscriptionRecurring,
variant: null,
validateKey,
validateKey: data.validateKey,
validatedAt,
expiredAt: expiresAt,
license: null,
@@ -242,18 +251,18 @@ export class LicenseService {
}
async deactivateTeamLicense(license: InstalledLicense) {
await this.fetchAffinePro(`/api/team/licenses/${license.key}/deactivate`, {
method: 'POST',
});
this.remoteCommand(
await licenseClient.deactivate({ licenseKey: license.key })
);
}
async updateTeamRecurring(key: string, recurring: SubscriptionRecurring) {
await this.fetchAffinePro(`/api/team/licenses/${key}/recurring`, {
method: 'POST',
body: JSON.stringify({
this.remoteCommand(
await licenseClient.updateRecurring({
licenseKey: key,
recurring,
}),
});
})
);
}
async createCustomerPortal(workspaceId: string) {
@@ -267,12 +276,12 @@ export class LicenseService {
throw new LicenseNotFound();
}
return this.fetchAffinePro<{ url: string }>(
`/api/team/licenses/${license.key}/create-customer-portal`,
{
method: 'POST',
}
const portal = this.remotePortal(
await licenseClient.createCustomerPortal({
licenseKey: license.key,
})
);
return { url: portal.url };
}
@OnEvent('workspace.members.updated')
@@ -301,12 +310,12 @@ export class LicenseService {
}
const count = await this.models.workspaceUser.chargedCount(workspaceId);
await this.fetchAffinePro(`/api/team/licenses/${license.key}/seats`, {
method: 'POST',
body: JSON.stringify({
this.remoteCommand(
await licenseClient.updateSeats({
licenseKey: license.key,
seats: count,
}),
});
})
);
// stripe payment is async, we can't directly the charge result in update calling
await this.waitUntilLicenseUpdated(license, count);
@@ -356,30 +365,28 @@ export class LicenseService {
private async revalidateRecurringLicense(license: InstalledLicense) {
try {
const res = await this.fetchAffinePro<License>(
`/api/team/licenses/${license.key}/health`,
{
headers: {
'x-validate-key': license.validateKey,
},
}
const res = this.remoteLicense(
await licenseClient.checkHealth({
licenseKey: license.key,
validateKey: license.validateKey,
})
);
const validatedAt = new Date();
const expiresAt = this.remoteLicenseExpiresAt(res);
const validateKey = res.res.headers.get('x-next-validate-key') ?? '';
const expiresAt = new Date(res.expiresAt);
const validateKey = res.validateKey || license.validateKey;
this.event.emit('workspace.subscription.activated', {
workspaceId: license.workspaceId,
plan: res.plan,
recurring: res.recurring,
plan: res.plan as SubscriptionPlan,
recurring: res.recurring as SubscriptionRecurring,
quantity: res.quantity,
});
await this.db.installedLicense.update({
where: { key: license.key },
data: {
quantity: res.quantity,
recurring: res.recurring,
recurring: res.recurring as SubscriptionRecurring,
validateKey,
validatedAt,
expiredAt: expiresAt,
@@ -390,7 +397,7 @@ export class LicenseService {
await this.entitlement.upsertFromSelfhostLicense({
workspaceId: license.workspaceId,
licenseKey: license.key,
recurring: res.recurring,
recurring: res.recurring as SubscriptionRecurring,
quantity: res.quantity,
expiresAt,
validatedAt,
@@ -401,7 +408,7 @@ export class LicenseService {
await this.entitlement.upsertFromValidatedSelfhostLicense({
workspaceId: license.workspaceId,
licenseKey: license.key,
recurring: res.recurring,
recurring: res.recurring as SubscriptionRecurring,
quantity: res.quantity,
expiresAt,
validatedAt,
@@ -430,55 +437,38 @@ export class LicenseService {
}
}
private remoteLicenseExpiresAt(license: Pick<License, 'endAt'>) {
const expiresAt = new Date(license.endAt);
if (!Number.isFinite(expiresAt.getTime()) || expiresAt <= new Date()) {
throw new LicenseExpired();
private remoteLicense(response: LicenseResponse) {
this.throwRemoteLicenseError(response.error);
if (!response.license) {
throw new InternalServerError('Invalid AFFiNE Pro license response.');
}
return expiresAt;
return response.license;
}
private async fetchAffinePro<T = any>(
path: string,
init?: RequestInit
): Promise<T & { res: Response }> {
const endpoint =
process.env.AFFINE_PRO_SERVER_ENDPOINT ?? 'https://app.affine.pro';
private remoteCommand(response: CommandResponse) {
this.throwRemoteLicenseError(response.error);
}
private remotePortal(response: PortalResponse) {
this.throwRemoteLicenseError(response.error);
if (!response.url) {
throw new InternalServerError('Invalid AFFiNE Pro portal response.');
}
return { url: response.url };
}
private throwRemoteLicenseError(
error?: LicenseError | null
): asserts error is null | undefined {
if (!error) return;
try {
const signal =
init?.signal ??
(AbortSignal.timeout
? AbortSignal.timeout(AFFINE_PRO_REQUEST_TIMEOUT)
: undefined);
const res = await fetch(endpoint + path, {
...init,
signal,
headers: {
'Content-Type': 'application/json',
...init?.headers,
},
});
if (!res.ok) {
const body = (await res.json()) as UserFriendlyError;
throw UserFriendlyError.fromUserFriendlyErrorJSON(body);
}
const data = (await res.json()) as T;
return {
...data,
res,
};
throw UserFriendlyError.fromUserFriendlyErrorJSON(JSON.parse(error.body));
} catch (e) {
if (e instanceof UserFriendlyError) {
throw e;
}
throw new InternalServerError(
e instanceof Error
? e.message
: 'Failed to contact with https://app.affine.pro'
'Failed to contact with https://app.affine.pro'
);
}
}
@@ -5,6 +5,7 @@ import {
InvalidOauthCallbackCode,
InvalidOauthResponse,
OnEvent,
safeFetch,
} from '../../../base';
import { OAuthProviderName } from '../config';
import { OAuthProviderFactory } from '../factory';
@@ -83,10 +84,16 @@ export abstract class OAuthProvider {
init?: RequestInit,
options?: { treatServerErrorAsInvalid?: boolean }
) {
const response = await fetch(url, {
...init,
headers: { ...init?.headers, Accept: 'application/json' },
});
const response = await safeFetch(
url,
{ ...init, headers: { ...init?.headers, Accept: 'application/json' } },
{
timeoutMs: 10_000,
maxRedirects: 3,
maxBytes: 1024 * 1024,
allowedHeaders: ['authorization', 'content-type', 'accept'],
}
);
const body = await response.text();
if (!response.ok) {
@@ -1,5 +1,10 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { createRemoteJWKSet, type JWTPayload, jwtVerify } from 'jose';
import {
createRemoteJWKSet,
customFetch,
type JWTPayload,
jwtVerify,
} from 'jose';
import { omit } from 'lodash-es';
import { z } from 'zod';
@@ -7,6 +12,7 @@ import {
ExponentialBackoffScheduler,
InvalidAuthState,
InvalidOauthResponse,
safeFetch,
URLHelper,
} from '../../../base';
import { OAuthOIDCProviderConfig, OAuthProviderName } from '../config';
@@ -59,12 +65,19 @@ type OIDCConfiguration = z.infer<typeof OIDCConfigurationSchema>;
const OIDC_DISCOVERY_INITIAL_RETRY_DELAY = 1000;
const OIDC_DISCOVERY_MAX_RETRY_DELAY = 60_000;
const OIDC_FETCH_OPTIONS = {
timeoutMs: 10_000,
maxRedirects: 3,
maxBytes: 1024 * 1024,
allowedHeaders: ['accept'],
};
@Injectable()
export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
override provider = OAuthProviderName.OIDC;
#endpoints: OIDCConfiguration | null = null;
#jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
private readonly oidcFetch = safeFetch;
readonly #retryScheduler = new ExponentialBackoffScheduler({
baseDelayMs: OIDC_DISCOVERY_INITIAL_RETRY_DELAY,
maxDelayMs: OIDC_DISCOVERY_MAX_RETRY_DELAY,
@@ -132,12 +145,10 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
}
try {
const res = await fetch(
const res = await this.oidcFetch(
`${config.issuer}/.well-known/openid-configuration`,
{
method: 'GET',
headers: { Accept: 'application/json' },
}
{ method: 'GET', headers: { Accept: 'application/json' } },
OIDC_FETCH_OPTIONS
);
if (generation !== this.#validationGeneration) {
@@ -163,7 +174,10 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
}
this.#endpoints = configuration;
this.#jwks = createRemoteJWKSet(new URL(configuration.jwks_uri));
this.#jwks = createRemoteJWKSet(new URL(configuration.jwks_uri), {
[customFetch]: (url, init) =>
this.oidcFetch(url, init, OIDC_FETCH_OPTIONS),
});
this.#retryScheduler.reset();
super.setup();
} catch (e) {
@@ -27,14 +27,6 @@ declare global {
payment: {
enabled: boolean;
showLifetimePrice: boolean;
/**
* @deprecated use payment.stripe.apiKey
*/
apiKey: string;
/**
* @deprecated use payment.stripe.webhookKey
*/
webhookKey: string;
stripe: ConfigItem<
{
/** Preferred place for Stripe API key */
@@ -70,16 +62,6 @@ defineModuleConfig('payment', {
desc: 'Whether enable lifetime price and allow user to pay for it.',
default: true,
},
apiKey: {
desc: '[Deprecated] Stripe API key. Use payment.stripe.apiKey instead.',
default: '',
env: 'STRIPE_API_KEY',
},
webhookKey: {
desc: '[Deprecated] Stripe webhook key. Use payment.stripe.webhookKey instead.',
default: '',
env: 'STRIPE_WEBHOOK_KEY',
},
stripe: {
desc: 'Stripe sdk options and credentials',
default: {
@@ -19,9 +19,7 @@ export class StripeWebhookController {
@Public()
@Post('/webhook')
async handleWebhook(@Req() req: RawBodyRequest<Request>) {
const nestedWebhookKey = this.config.payment.stripe?.webhookKey;
const legacyWebhookKey = this.config.payment.webhookKey;
const webhookKey = nestedWebhookKey || legacyWebhookKey || '';
const webhookKey = this.config.payment.stripe?.webhookKey || '';
// Retrieve the event by verifying the signature using the raw body and secret.
const signature = req.headers['stripe-signature'];
try {
@@ -256,19 +256,21 @@ export abstract class SubscriptionManager {
}
async getPrice(lookupKey: LookupKey): Promise<KnownStripePrice | null> {
let key: string;
try {
key = encodeLookupKey(lookupKey);
} catch {
return null;
}
const prices = await this.stripe.prices.list({
lookup_keys: [encodeLookupKey(lookupKey)],
lookup_keys: [key],
limit: 1,
});
const price = prices.data[0];
return price
? {
lookupKey,
price,
}
: null;
return price ? { lookupKey, price } : null;
}
protected async getCouponFromPromotionCode(
@@ -7,23 +7,18 @@ import { z } from 'zod';
import {
Config,
EventBus,
InternalServerError,
InvalidCheckoutParameters,
ManagedByAppStoreOrPlay,
Mutex,
OneMonth,
OnEvent,
OneYear,
SubscriptionAlreadyExists,
SubscriptionPlanNotFound,
TooManyRequest,
URLHelper,
} from '../../../base';
import { EntitlementService } from '../../../core/entitlement';
import { EarlyAccessType, FeatureService } from '../../../core/features';
import { StripeFactory } from '../stripe';
import {
CouponType,
KnownStripeInvoice,
KnownStripePrice,
KnownStripeSubscription,
@@ -32,7 +27,6 @@ import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
SubscriptionVariant,
} from '../types';
import {
activeSubscriptionWhere,
@@ -42,11 +36,8 @@ import {
} from './common';
interface PriceStrategyStatus {
proEarlyAccess: boolean;
aiEarlyAccess: boolean;
proSubscribed: boolean;
aiSubscribed: boolean;
onetime: boolean;
}
export const UserSubscriptionIdentity = z.object({
@@ -67,7 +58,6 @@ export class UserSubscriptionManager extends SubscriptionManager {
stripeProvider: StripeFactory,
db: PrismaClient,
private readonly config: Config,
private readonly feature: FeatureService,
private readonly event: EventBus,
private readonly url: URLHelper,
private readonly mutex: Mutex,
@@ -78,22 +68,12 @@ export class UserSubscriptionManager extends SubscriptionManager {
async filterPrices(
prices: KnownStripePrice[],
customer?: UserStripeCustomer
_customer?: UserStripeCustomer
) {
const strategyStatus = customer
? await this.strategyStatus(customer)
: {
proEarlyAccess: false,
aiEarlyAccess: false,
proSubscribed: false,
aiSubscribed: false,
onetime: false,
};
const availablePrices: KnownStripePrice[] = [];
for (const price of prices) {
if (await this.isPriceAvailable(price, strategyStatus)) {
if (await this.isPriceAvailable(price)) {
availablePrices.push(price);
}
}
@@ -107,8 +87,9 @@ export class UserSubscriptionManager extends SubscriptionManager {
{ user }: z.infer<typeof UserSubscriptionCheckoutArgs>
) {
if (
lookupKey.plan !== SubscriptionPlan.Pro &&
lookupKey.plan !== SubscriptionPlan.AI
(lookupKey.plan !== SubscriptionPlan.Pro &&
lookupKey.plan !== SubscriptionPlan.AI) ||
lookupKey.variant !== null
) {
throw new InvalidCheckoutParameters();
}
@@ -125,15 +106,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
active &&
// do not allow to re-subscribe unless
!(
/* current subscription is a onetime subscription and so as the one that's checking out */
(
(active.variant === SubscriptionVariant.Onetime &&
lookupKey.variant === SubscriptionVariant.Onetime) ||
/* current subscription is normal subscription and is checking-out a lifetime subscription */
(active.recurring !== SubscriptionRecurring.Lifetime &&
active.variant !== SubscriptionVariant.Onetime &&
lookupKey.recurring === SubscriptionRecurring.Lifetime)
)
active.recurring !== SubscriptionRecurring.Lifetime &&
lookupKey.recurring === SubscriptionRecurring.Lifetime
)
) {
throw new SubscriptionAlreadyExists({ plan: lookupKey.plan });
@@ -141,12 +115,9 @@ export class UserSubscriptionManager extends SubscriptionManager {
const customer = await this.getOrCreateCustomer(user.id);
const strategy = await this.strategyStatus(customer);
const price = await this.autoPrice(lookupKey, strategy);
const price = await this.getPrice(lookupKey);
if (
!price ||
!(await this.isPriceAvailable(price, { ...strategy, onetime: true }))
) {
if (!price || !(await this.isPriceAvailable(price))) {
throw new SubscriptionPlanNotFound({
plan: lookupKey.plan,
recurring: lookupKey.recurring,
@@ -154,10 +125,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
}
const discounts = await (async () => {
const coupon = await this.getBuildInCoupon(customer, price);
if (coupon) {
return { discounts: [{ coupon }] };
} else if (params.coupon) {
if (params.coupon) {
const couponId = await this.getCouponFromPromotionCode(
params.coupon,
customer
@@ -179,10 +147,9 @@ export class UserSubscriptionManager extends SubscriptionManager {
return undefined;
})();
// mode: 'subscription' or 'payment' for lifetime and onetime payment
// mode: 'subscription' or 'payment' for lifetime payment
const mode =
lookupKey.recurring === SubscriptionRecurring.Lifetime ||
lookupKey.variant === SubscriptionVariant.Onetime
lookupKey.recurring === SubscriptionRecurring.Lifetime
? {
mode: 'payment' as const,
invoice_creation: {
@@ -339,37 +306,6 @@ export class UserSubscriptionManager extends SubscriptionManager {
});
}
private async getBuildInCoupon(
customer: UserStripeCustomer,
price: KnownStripePrice
) {
const strategyStatus = await this.strategyStatus(customer);
// onetime price is allowed for checkout
strategyStatus.onetime = true;
if (!(await this.isPriceAvailable(price, strategyStatus))) {
return null;
}
let coupon: CouponType | undefined;
if (price.lookupKey.variant === SubscriptionVariant.EA) {
if (price.lookupKey.plan === SubscriptionPlan.Pro) {
coupon = CouponType.ProEarlyAccessOneYearFree;
} else if (price.lookupKey.plan === SubscriptionPlan.AI) {
coupon = CouponType.AIEarlyAccessOneYearFree;
}
} else if (price.lookupKey.plan === SubscriptionPlan.AI) {
const { proEarlyAccess, aiSubscribed } = strategyStatus;
if (proEarlyAccess && !aiSubscribed) {
coupon = CouponType.ProEarlyAccessAIOneYearFree;
}
}
return coupon;
}
async saveInvoice(knownInvoice: KnownStripeInvoice) {
const { userId, lookupKey, stripeInvoice } = knownInvoice;
this.assertUserIdExists(userId);
@@ -387,11 +323,11 @@ export class UserSubscriptionManager extends SubscriptionManager {
},
});
// onetime and lifetime subscription is a special "subscription" that doesn't get involved with stripe subscription system
// we track the deals by invoice only.
// Lifetime subscription does not get involved with the Stripe subscription system.
// We track the deal by invoice only.
if (stripeInvoice.status === 'paid') {
await using lock = await this.mutex.acquire(
`redeem-onetime-subscription:${stripeInvoice.id}`
`redeem-lifetime-subscription:${stripeInvoice.id}`
);
if (!lock) {
@@ -400,8 +336,6 @@ export class UserSubscriptionManager extends SubscriptionManager {
if (lookupKey.recurring === SubscriptionRecurring.Lifetime) {
await this.saveLifetimeSubscription(knownInvoice);
} else if (lookupKey.variant === SubscriptionVariant.Onetime) {
await this.saveOnetimePaymentSubscription(knownInvoice);
}
}
@@ -470,106 +404,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
});
}
async saveOnetimePaymentSubscription(knownInvoice: KnownStripeInvoice) {
this.assertUserIdExists(knownInvoice.userId);
const { userId, lookupKey, stripeInvoice } = knownInvoice;
const invoice = await this.db.invoice.findUnique({
where: {
stripeInvoiceId: stripeInvoice.id,
},
});
if (!invoice) {
// never happens
throw new InternalServerError('Invoice not found');
}
if (invoice.onetimeSubscriptionRedeemed) {
return;
}
await this.db.invoice.update({
select: {
onetimeSubscriptionRedeemed: true,
},
where: {
stripeInvoiceId: stripeInvoice.id,
},
data: { onetimeSubscriptionRedeemed: true },
});
const existingSubscription = await this.db.subscription.findUnique({
where: {
targetId_plan: {
targetId: userId,
plan: lookupKey.plan,
},
},
});
const subscriptionTime =
lookupKey.recurring === SubscriptionRecurring.Monthly
? OneMonth
: OneYear;
let subscription: Subscription;
// extends the subscription time if exists
if (existingSubscription) {
if (!existingSubscription.end) {
throw new InternalServerError(
'Unexpected onetime subscription with no end date'
);
}
const period =
// expired, reset the period
existingSubscription.end <= new Date()
? {
start: new Date(),
end: new Date(Date.now() + subscriptionTime),
}
: {
end: new Date(
existingSubscription.end.getTime() + subscriptionTime
),
};
subscription = await this.db.subscription.update({
where: {
id: existingSubscription.id,
},
data: period,
});
} else {
subscription = await this.db.subscription.create({
data: {
targetId: userId,
stripeSubscriptionId: null,
...lookupKey,
start: new Date(),
end: new Date(Date.now() + subscriptionTime),
status: SubscriptionStatus.Active,
nextBillAt: null,
},
});
}
this.event.emit('user.subscription.activated', {
userId,
plan: lookupKey.plan,
recurring: lookupKey.recurring,
});
await this.entitlement.upsertFromCloudSubscription({
...subscription,
targetId: userId,
});
return subscription;
}
async revokeOnetimeOrLifetime(knownInvoice: KnownStripeInvoice) {
async revokeLifetime(knownInvoice: KnownStripeInvoice) {
this.assertUserIdExists(knownInvoice.userId);
const { userId, lookupKey } = knownInvoice;
@@ -609,7 +444,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
});
}
async restoreOnetimeOrLifetime(knownInvoice: KnownStripeInvoice) {
async restoreLifetime(knownInvoice: KnownStripeInvoice) {
this.assertUserIdExists(knownInvoice.userId);
const { userId, lookupKey, stripeInvoice } = knownInvoice;
@@ -627,18 +462,6 @@ export class UserSubscriptionManager extends SubscriptionManager {
? stripeInvoice.created
: Date.now() / 1000);
let end: Date | null = null;
if (lookupKey.recurring === SubscriptionRecurring.Lifetime) {
end = null;
} else if (lookupKey.variant === SubscriptionVariant.Onetime) {
const isMonthly = lookupKey.recurring === SubscriptionRecurring.Monthly;
const duration = isMonthly ? OneMonth : OneYear;
end = subscription?.end ?? new Date(start * 1000 + duration);
} else {
end = subscription?.end ?? null;
}
if (subscription) {
const saved = await this.db.subscription.update({
where: { id: subscription.id },
@@ -647,7 +470,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
canceledAt: null,
nextBillAt: null,
start: subscription.start ?? new Date(start * 1000),
end,
end: null,
},
});
await this.entitlement.upsertFromCloudSubscription(saved);
@@ -658,7 +481,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
stripeSubscriptionId: null,
...lookupKey,
start: new Date(start * 1000),
end,
end: null,
status: SubscriptionStatus.Active,
nextBillAt: null,
},
@@ -673,111 +496,43 @@ export class UserSubscriptionManager extends SubscriptionManager {
});
}
private async autoPrice(lookupKey: LookupKey, strategy: PriceStrategyStatus) {
// auto select ea variant when available if not specified
let variant: SubscriptionVariant | null = lookupKey.variant;
if (!variant) {
// make the if conditions separated, more readable
// pro early access
if (
lookupKey.plan === SubscriptionPlan.Pro &&
lookupKey.recurring === SubscriptionRecurring.Yearly &&
strategy.proEarlyAccess &&
!strategy.proSubscribed
) {
variant = SubscriptionVariant.EA;
}
// ai early access
if (
lookupKey.plan === SubscriptionPlan.AI &&
lookupKey.recurring === SubscriptionRecurring.Yearly &&
strategy.aiEarlyAccess &&
!strategy.aiSubscribed
) {
variant = SubscriptionVariant.EA;
}
}
return this.getPrice({
plan: lookupKey.plan,
recurring: lookupKey.recurring,
variant,
});
}
private async isPriceAvailable(
price: KnownStripePrice,
strategy: PriceStrategyStatus
) {
private async isPriceAvailable(price: KnownStripePrice) {
if (price.lookupKey.plan === SubscriptionPlan.Pro) {
return this.isProPriceAvailable(price, strategy);
return this.isProPriceAvailable(price);
}
if (price.lookupKey.plan === SubscriptionPlan.AI) {
return this.isAIPriceAvailable(price, strategy);
return this.isAIPriceAvailable(price);
}
return false;
}
private async isProPriceAvailable(
{ lookupKey }: KnownStripePrice,
{ proEarlyAccess, proSubscribed, onetime }: PriceStrategyStatus
) {
private async isProPriceAvailable({ lookupKey }: KnownStripePrice) {
if (lookupKey.recurring === SubscriptionRecurring.Lifetime) {
return this.config.payment.showLifetimePrice;
}
if (lookupKey.variant === SubscriptionVariant.Onetime) {
return onetime;
}
// no special price for monthly plan
if (lookupKey.recurring === SubscriptionRecurring.Monthly) {
return true;
}
// show EA price instead of normal price if early access is available
return proEarlyAccess && !proSubscribed
? lookupKey.variant === SubscriptionVariant.EA
: lookupKey.variant !== SubscriptionVariant.EA;
return lookupKey.variant === null;
}
private async isAIPriceAvailable(
{ lookupKey }: KnownStripePrice,
{ aiEarlyAccess, aiSubscribed, onetime }: PriceStrategyStatus
) {
private async isAIPriceAvailable({ lookupKey }: KnownStripePrice) {
// no lifetime price for AI
if (lookupKey.recurring === SubscriptionRecurring.Lifetime) {
return false;
}
// never show onetime prices
if (lookupKey.variant === SubscriptionVariant.Onetime) {
return onetime;
}
// show EA price instead of normal price if early access is available
return aiEarlyAccess && !aiSubscribed
? lookupKey.variant === SubscriptionVariant.EA
: lookupKey.variant !== SubscriptionVariant.EA;
return lookupKey.variant === null;
}
private async strategyStatus(
customer: UserStripeCustomer
): Promise<PriceStrategyStatus> {
const proEarlyAccess = await this.feature.isEarlyAccessUser(
customer.userId,
EarlyAccessType.App
);
const aiEarlyAccess = await this.feature.isEarlyAccessUser(
customer.userId,
EarlyAccessType.AI
);
let proSubscribed = false;
let aiSubscribed = false;
@@ -786,8 +541,6 @@ export class UserSubscriptionManager extends SubscriptionManager {
status: 'all',
});
// if the early access user had early access subscription in the past, but it got canceled or past due,
// the user will lose the early access privilege
for (const sub of subscriptions.data) {
const lookupKey = retriveLookupKeyFromStripeSubscription(sub);
if (!lookupKey) {
@@ -803,13 +556,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
}
}
return {
proEarlyAccess,
aiEarlyAccess,
proSubscribed,
aiSubscribed,
onetime: false,
};
return { proSubscribed, aiSubscribed };
}
private assertUserIdExists(
@@ -166,14 +166,6 @@ export class InvoiceType implements Partial<Invoice> {
@Field(() => Date)
updatedAt!: Date;
// deprecated fields
@Field(() => String, {
name: 'id',
nullable: true,
deprecationReason: 'removed',
})
stripeInvoiceId?: string;
@Field(() => SubscriptionPlan, {
nullable: true,
deprecationReason: 'removed',
@@ -475,12 +467,7 @@ export class UserSubscriptionResolver {
) {}
private normalizeSubscription(s: Subscription) {
if (
s.variant &&
![SubscriptionVariant.EA, SubscriptionVariant.Onetime].includes(
s.variant as SubscriptionVariant
)
) {
if (s.variant && s.variant !== SubscriptionVariant.Onetime) {
s.variant = null;
}
return s;
@@ -55,7 +55,6 @@ import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
SubscriptionVariant,
} from './types';
export const CheckoutExtraArgs = z.union([
@@ -536,9 +535,8 @@ export class SubscriptionService {
reason === 'dispute_open' ||
reason === 'dispute_lost';
const restore = reason === 'dispute_won';
const isOneTimeOrLifetime =
knownInvoice.lookupKey.recurring === SubscriptionRecurring.Lifetime ||
knownInvoice.lookupKey.variant === SubscriptionVariant.Onetime;
const isLifetime =
knownInvoice.lookupKey.recurring === SubscriptionRecurring.Lifetime;
if (restore) {
if (invoice.subscription) {
@@ -564,11 +562,11 @@ export class SubscriptionService {
}
if (
isOneTimeOrLifetime &&
isLifetime &&
(knownInvoice.lookupKey.plan === SubscriptionPlan.Pro ||
knownInvoice.lookupKey.plan === SubscriptionPlan.AI)
) {
await this.userManager.restoreOnetimeOrLifetime(knownInvoice);
await this.userManager.restoreLifetime(knownInvoice);
}
return;
@@ -615,11 +613,11 @@ export class SubscriptionService {
}
if (
isOneTimeOrLifetime &&
isLifetime &&
(knownInvoice.lookupKey.plan === SubscriptionPlan.Pro ||
knownInvoice.lookupKey.plan === SubscriptionPlan.AI)
) {
await this.userManager.revokeOnetimeOrLifetime(knownInvoice);
await this.userManager.revokeLifetime(knownInvoice);
return;
}
@@ -7,7 +7,6 @@ import {
decodeLookupKey,
DEFAULT_PRICES,
SubscriptionRecurring,
SubscriptionVariant,
} from './types';
@Injectable()
@@ -39,20 +38,18 @@ export class StripeFactory {
}
setup() {
// Prefer new keys under payment.stripe.*, fallback to legacy root keys for backward compatibility
const {
apiKey: nestedApiKey,
apiKey,
webhookKey: _,
...config
} = this.config.payment.stripe || {};
// NOTE:
// we always fake a key if not set because `new Stripe` will complain if it's empty string
// this will make code cleaner than providing `Stripe` instance as optional one.
const apiKey =
nestedApiKey || this.config.payment.apiKey || 'stripe-api-key';
const stripeApiKey = apiKey || 'stripe-api-key';
// TODO@(@darkskygit): use per-requests api key injection
this.#stripe = new Stripe(apiKey, config);
this.#stripe = new Stripe(stripeApiKey, config);
if (this.config.payment.enabled) {
this.server.enableFeature(ServerFeature.Payment);
} else {
@@ -107,8 +104,7 @@ export class StripeFactory {
lookup_key: key,
tax_behavior: 'inclusive',
recurring:
lookupKey.recurring === SubscriptionRecurring.Lifetime ||
lookupKey.variant === SubscriptionVariant.Onetime
lookupKey.recurring === SubscriptionRecurring.Lifetime
? undefined
: {
interval:
@@ -20,7 +20,6 @@ export enum SubscriptionPlan {
}
export enum SubscriptionVariant {
EA = 'earlyaccess',
Onetime = 'onetime',
}
@@ -44,12 +43,6 @@ export enum InvoiceStatus {
Uncollectible = 'uncollectible',
}
export enum CouponType {
ProEarlyAccessOneYearFree = 'pro_ea_one_year_free',
AIEarlyAccessOneYearFree = 'ai_ea_one_year_free',
ProEarlyAccessAIOneYearFree = 'ai_pro_ea_one_year_free',
}
declare global {
interface Events {
'user.subscription.activated': {
@@ -199,11 +192,6 @@ export const DEFAULT_PRICES = new Map([
price: 8100,
},
],
// only EA for yearly pro
[
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`,
{ product: 'AFFiNE Pro', price: 5000 },
],
[
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Lifetime}`,
{
@@ -211,29 +199,11 @@ export const DEFAULT_PRICES = new Map([
price: 49900,
},
],
[
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}_${SubscriptionVariant.Onetime}`,
{ product: 'AFFiNE Pro - One Month', price: 799 },
],
[
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`,
{ product: 'AFFiNE Pro - One Year', price: 8100 },
],
// ai
[
`${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}`,
{ product: 'AFFiNE AI', price: 10680 },
],
// only EA for yearly AI
[
`${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`,
{ product: 'AFFiNE AI', price: 9900 },
],
[
`${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`,
{ product: 'AFFiNE AI - One Year', price: 10680 },
],
// team
[
@@ -284,7 +254,7 @@ export function decodeLookupKey(key: string): LookupKey {
return {
plan: plan as SubscriptionPlan,
recurring: recurring as SubscriptionRecurring,
variant: variant as SubscriptionVariant,
variant: (variant as SubscriptionVariant) ?? null,
};
}
@@ -60,6 +60,8 @@ function toBadRequestReason(reason: SSRFBlockReason) {
return 'Failed to resolve hostname';
case 'too_many_redirects':
return 'Too many redirects';
default:
return 'Blocked by SSRF protection';
}
}