Compare commits

...

1 Commits

Author SHA1 Message Date
liuyi
ba78a6bd45 refactor(server): use duration helper 2025-06-27 11:12:15 +08:00
33 changed files with 99 additions and 119 deletions

View File

@@ -4,6 +4,7 @@ import ava, { TestFn } from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
import request from 'supertest'; import request from 'supertest';
import { Due } from '../../base';
import { AuthModule, CurrentUser, Public, Session } from '../../core/auth'; import { AuthModule, CurrentUser, Public, Session } from '../../core/auth';
import { AuthService } from '../../core/auth/service'; import { AuthService } from '../../core/auth/service';
import { Models } from '../../models'; import { Models } from '../../models';
@@ -125,7 +126,7 @@ test('should be able to refresh session if needed', async t => {
sessionId, sessionId,
}, },
data: { data: {
expiresAt: new Date(Date.now() + 1000 * 60 * 60 /* expires in 1 hour */), expiresAt: Due.after('1h'),
}, },
}); });

View File

@@ -3,6 +3,7 @@ import { PrismaClient } from '@prisma/client';
import test from 'ava'; import test from 'ava';
import * as Sinon from 'sinon'; import * as Sinon from 'sinon';
import { Due } from '../../base';
import { DocStorageModule, PgWorkspaceDocStorageAdapter } from '../../core/doc'; import { DocStorageModule, PgWorkspaceDocStorageAdapter } from '../../core/doc';
import { DocStorageOptions } from '../../core/doc/options'; import { DocStorageOptions } from '../../core/doc/options';
import { DocRecord } from '../../core/doc/storage'; import { DocRecord } from '../../core/doc/storage';
@@ -122,7 +123,7 @@ test('should create history if time diff is larger than interval config and stat
// @ts-expect-error private method // @ts-expect-error private method
Sinon.stub(adapter, 'lastDocHistory').resolves({ Sinon.stub(adapter, 'lastDocHistory').resolves({
timestamp: new Date(timestamp.getTime() - 1000 * 60 * 20), timestamp: Due.before('20m', timestamp),
state: Buffer.from([0, 1]), state: Buffer.from([0, 1]),
}); });

View File

@@ -5,6 +5,7 @@ import { Config } from '../../base/config';
import { SessionModel } from '../../models/session'; import { SessionModel } from '../../models/session';
import { UserModel } from '../../models/user'; import { UserModel } from '../../models/user';
import { createTestingModule, type TestingModule } from '../utils'; import { createTestingModule, type TestingModule } from '../utils';
import { Due } from '../../base/utils';
interface Context { interface Context {
config: Config; config: Config;
@@ -137,9 +138,7 @@ test('should not refresh userSession when expires time not hit ttr', async t =>
let newExpiresAt = let newExpiresAt =
await t.context.session.refreshUserSessionIfNeeded(userSession); await t.context.session.refreshUserSessionIfNeeded(userSession);
t.is(newExpiresAt, undefined); t.is(newExpiresAt, undefined);
userSession.expiresAt = new Date( userSession.expiresAt = Due.before(t.context.config.auth.session.ttr);
userSession.expiresAt!.getTime() - t.context.config.auth.session.ttr * 1000
);
newExpiresAt = newExpiresAt =
await t.context.session.refreshUserSessionIfNeeded(userSession); await t.context.session.refreshUserSessionIfNeeded(userSession);
t.is(newExpiresAt, undefined); t.is(newExpiresAt, undefined);
@@ -154,9 +153,9 @@ test('should not refresh userSession when expires time hit ttr', async t => {
user.id, user.id,
session.id session.id
); );
const ttr = t.context.config.auth.session.ttr * 2;
userSession.expiresAt = new Date( userSession.expiresAt = new Date(
userSession.expiresAt!.getTime() - ttr * 1000 userSession.expiresAt!.getTime() -
Due.ms(t.context.config.auth.session.ttr) * 2
); );
const newExpiresAt = const newExpiresAt =
await t.context.session.refreshUserSessionIfNeeded(userSession); await t.context.session.refreshUserSessionIfNeeded(userSession);

View File

@@ -6,7 +6,7 @@ import Sinon from 'sinon';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { AppModule } from '../../app.module'; import { AppModule } from '../../app.module';
import { EventBus } from '../../base'; import { Due, EventBus } from '../../base';
import { ConfigFactory, ConfigModule } from '../../base/config'; import { ConfigFactory, ConfigModule } from '../../base/config';
import { CurrentUser } from '../../core/auth'; import { CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service'; import { AuthService } from '../../core/auth/service';
@@ -129,8 +129,8 @@ const sub: Stripe.Subscription = {
object: 'subscription', object: 'subscription',
cancel_at_period_end: false, cancel_at_period_end: false,
canceled_at: null, canceled_at: null,
current_period_end: unixNow() + 60 * 60 * 24 * 30, current_period_end: Due.after('30d').getTime() / 1000,
current_period_start: unixNow() - 60 * 60 * 24 * 1, current_period_start: Due.before('1d').getTime() / 1000,
// @ts-expect-error stub // @ts-expect-error stub
customer: { customer: {
id: 'cus_1', id: 'cus_1',
@@ -914,7 +914,7 @@ const subscriptionSchedule: Stripe.SubscriptionSchedule = {
}, },
], ],
start_date: unixNow(), start_date: unixNow(),
end_date: unixNow() + 30 * 24 * 60 * 60, end_date: Due.after('30d').getTime() / 1000,
}, },
{ {
items: [ items: [
@@ -924,7 +924,7 @@ const subscriptionSchedule: Stripe.SubscriptionSchedule = {
quantity: 1, quantity: 1,
}, },
], ],
start_date: unixNow() + 30 * 24 * 60 * 60, start_date: Due.after('30d').getTime() / 1000,
}, },
], ],
}; };
@@ -1550,10 +1550,7 @@ test('should be able to subscribe onetime payment subscription', async t => {
t.is(subInDB?.recurring, SubscriptionRecurring.Monthly); t.is(subInDB?.recurring, SubscriptionRecurring.Monthly);
t.is(subInDB?.status, SubscriptionStatus.Active); t.is(subInDB?.status, SubscriptionStatus.Active);
t.is(subInDB?.stripeSubscriptionId, null); t.is(subInDB?.stripeSubscriptionId, null);
t.is( t.is(subInDB?.end?.toDateString(), Due.after('30d').toDateString());
subInDB?.end?.toDateString(),
new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toDateString()
);
}); });
test('should be able to accumulate onetime payment subscription period', async t => { test('should be able to accumulate onetime payment subscription period', async t => {
@@ -1574,7 +1571,7 @@ test('should be able to accumulate onetime payment subscription period', async t
}); });
// add 365 days // add 365 days
t.is(subInDB!.end!.getTime(), end.getTime() + 365 * 24 * 60 * 60 * 1000); t.is(subInDB!.end!.getTime(), Due.after('1y', end).getTime());
}); });
test('should be able to recalculate onetime payment subscription period after expiration', async t => { test('should be able to recalculate onetime payment subscription period after expiration', async t => {
@@ -1599,10 +1596,7 @@ test('should be able to recalculate onetime payment subscription period after ex
}); });
// add 365 days from now // add 365 days from now
t.is( t.is(subInDB?.end?.toDateString(), Due.after('1y').toDateString());
subInDB?.end?.toDateString(),
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toDateString()
);
}); });
test('should not accumulate onetime payment subscription period for redeemed invoices', async t => { test('should not accumulate onetime payment subscription period for redeemed invoices', async t => {
@@ -1617,10 +1611,7 @@ test('should not accumulate onetime payment subscription period for redeemed inv
where: { targetId: u1.id }, where: { targetId: u1.id },
}); });
t.is( t.is(subInDB?.end?.toDateString(), Due.after('1y').toDateString());
subInDB?.end?.toDateString(),
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toDateString()
);
}); });
// TEAM // TEAM

View File

@@ -1,10 +1,11 @@
import Redis from 'ioredis'; import Redis from 'ioredis';
import { type Duration, Due } from '../utils';
export interface CacheSetOptions { export interface CacheSetOptions {
/** /**
* in milliseconds * in milliseconds
*/ */
ttl?: number; ttl?: Duration;
} }
export class CacheProvider { export class CacheProvider {
@@ -30,7 +31,7 @@ export class CacheProvider {
): Promise<boolean> { ): Promise<boolean> {
if (opts.ttl) { if (opts.ttl) {
return this.redis return this.redis
.set(key, JSON.stringify(value), 'PX', opts.ttl) .set(key, JSON.stringify(value), 'PX', Due.ms(opts.ttl))
.then(() => true) .then(() => true)
.catch(() => false); .catch(() => false);
} }
@@ -56,7 +57,7 @@ export class CacheProvider {
): Promise<boolean> { ): Promise<boolean> {
if (opts.ttl) { if (opts.ttl) {
return this.redis return this.redis
.set(key, JSON.stringify(value), 'PX', opts.ttl, 'NX') .set(key, JSON.stringify(value), 'PX', Due.ms(opts.ttl), 'NX')
.then(v => !!v) .then(v => !!v)
.catch(() => false); .catch(() => false);
} }

View File

@@ -103,8 +103,8 @@ test('should override correctly', t => {
// keyed config // keyed config
// 'session.ttl', 'session.ttr' // 'session.ttl', 'session.ttr'
session: { session: {
ttl: 2000, ttl: '1M',
ttr: 1000, ttr: '1d',
}, },
}, },
storages: { storages: {
@@ -131,7 +131,7 @@ test('should override correctly', t => {
}, },
allowSignup: true, allowSignup: true,
session: { session: {
ttl: 3000, ttl: '2M',
}, },
}, },
storages: { storages: {
@@ -159,8 +159,8 @@ test('should override correctly', t => {
// right merged to left // right merged to left
t.deepEqual(config.auth.session, { t.deepEqual(config.auth.session, {
ttl: 3000, ttl: '2M',
ttr: 1000, ttr: '1d',
}); });
// right covered left // right covered left

View File

@@ -6,6 +6,7 @@ import { type QueueOptions } from 'bullmq';
import { Config } from '../../config'; import { Config } from '../../config';
import { QueueRedis } from '../../redis'; import { QueueRedis } from '../../redis';
import { Due } from '../../utils';
import { Queue, QUEUES } from './def'; import { Queue, QUEUES } from './def';
import { JobExecutor } from './executor'; import { JobExecutor } from './executor';
import { JobQueue } from './queue'; import { JobQueue } from './queue';
@@ -40,7 +41,7 @@ export class JobModule {
...QUEUES.map(name => { ...QUEUES.map(name => {
if (name === Queue.NIGHTLY_JOB) { if (name === Queue.NIGHTLY_JOB) {
// avoid nightly jobs been run multiple times // avoid nightly jobs been run multiple times
return { name, removeOnComplete: { age: 1000 * 60 * 60 } }; return { name, removeOnComplete: { age: Due.ms('1m') } };
} }
return { name }; return { name };
}) })

View File

@@ -22,7 +22,7 @@ import {
PutObjectMetadata, PutObjectMetadata,
StorageProvider, StorageProvider,
} from './provider'; } from './provider';
import { autoMetadata, SIGNED_URL_EXPIRED, toBuffer } from './utils'; import { autoMetadata, SIGNED_URL_EXPIRED_SEC, toBuffer } from './utils';
export interface S3StorageConfig extends S3ClientConfig { export interface S3StorageConfig extends S3ClientConfig {
usePresignedURL?: { usePresignedURL?: {
@@ -138,7 +138,7 @@ export class S3StorageProvider implements StorageProvider {
Bucket: this.bucket, Bucket: this.bucket,
Key: key, Key: key,
}), }),
{ expiresIn: SIGNED_URL_EXPIRED } { expiresIn: SIGNED_URL_EXPIRED_SEC }
); );
return { return {

View File

@@ -4,6 +4,7 @@ import { crc32 } from '@node-rs/crc32';
import { getStreamAsBuffer } from 'get-stream'; import { getStreamAsBuffer } from 'get-stream';
import { getMime } from '../../../native'; import { getMime } from '../../../native';
import { Due } from '../../utils';
import { BlobInputType, PutObjectMetadata } from './provider'; import { BlobInputType, PutObjectMetadata } from './provider';
export async function toBuffer(input: BlobInputType): Promise<Buffer> { export async function toBuffer(input: BlobInputType): Promise<Buffer> {
@@ -43,4 +44,4 @@ export function autoMetadata(
return metadata; return metadata;
} }
export const SIGNED_URL_EXPIRED = 60 * 60; // 1 hour export const SIGNED_URL_EXPIRED_SEC = Due.s('1h');

View File

@@ -53,26 +53,28 @@ function parse(str: string): DurationInput {
return input; return input;
} }
export type Duration = string | DurationInput;
export const Due = { export const Due = {
ms: (dueStr: string | DurationInput) => { ms: (duration: Duration) => {
const input = typeof dueStr === 'string' ? parse(dueStr) : dueStr; const input = typeof duration === 'string' ? parse(duration) : duration;
return Object.entries(input).reduce((duration, [unit, val]) => { return Object.entries(input).reduce((duration, [unit, val]) => {
return duration + UnitToSecMap[unit as DurationUnit] * (val || 0) * 1000; return duration + UnitToSecMap[unit as DurationUnit] * (val || 0) * 1000;
}, 0); }, 0);
}, },
s: (dueStr: string | DurationInput) => { s: (duration: Duration) => {
const input = typeof dueStr === 'string' ? parse(dueStr) : dueStr; const input = typeof duration === 'string' ? parse(duration) : duration;
return Object.entries(input).reduce((duration, [unit, val]) => { return Object.entries(input).reduce((duration, [unit, val]) => {
return duration + UnitToSecMap[unit as DurationUnit] * (val || 0); return duration + UnitToSecMap[unit as DurationUnit] * (val || 0);
}, 0); }, 0);
}, },
parse, parse,
after: (dueStr: string | number | DurationInput, date?: Date) => { after: (duration: Duration, date?: Date) => {
const timestamp = typeof dueStr === 'number' ? dueStr : Due.ms(dueStr); const timestamp = Due.ms(duration);
return new Date((date?.getTime() ?? Date.now()) + timestamp); return new Date((date?.getTime() ?? Date.now()) + timestamp);
}, },
before: (dueStr: string | number | DurationInput, date?: Date) => { before: (duration: Duration, date?: Date) => {
const timestamp = typeof dueStr === 'number' ? dueStr : Due.ms(dueStr); const timestamp = Due.ms(duration);
return new Date((date?.getTime() ?? Date.now()) - timestamp); return new Date((date?.getTime() ?? Date.now()) - timestamp);
}, },
}; };

View File

@@ -1,4 +1,3 @@
export const OneKB = 1024; export const OneKB = 1024;
export const OneMB = OneKB * OneKB; export const OneMB = OneKB * OneKB;
export const OneGB = OneKB * OneMB; export const OneGB = OneKB * OneMB;
export const OneDay = 1000 * 60 * 60 * 24;

View File

@@ -4,8 +4,8 @@ import { defineModuleConfig } from '../../base';
export interface AuthConfig { export interface AuthConfig {
session: { session: {
ttl: number; ttl: string;
ttr: number; ttr: string;
}; };
allowSignup: boolean; allowSignup: boolean;
requireEmailDomainVerification: boolean; requireEmailDomainVerification: boolean;
@@ -60,10 +60,10 @@ defineModuleConfig('auth', {
}, },
'session.ttl': { 'session.ttl': {
desc: 'Application auth expiration time in seconds.', desc: 'Application auth expiration time in seconds.',
default: 60 * 60 * 24 * 15, // 15 days default: '15d',
}, },
'session.ttr': { 'session.ttr': {
desc: 'Application auth time to refresh in seconds.', desc: 'Application auth time to refresh in seconds.',
default: 60 * 60 * 24 * 7, // 7 days default: '7d',
}, },
}); });

View File

@@ -199,21 +199,17 @@ export class AuthController {
throw new WrongSignInCredentials({ email }); throw new WrongSignInCredentials({ email });
} }
const ttlInSec = 30 * 60; const ttl = '30m';
const token = await this.models.verificationToken.create( const token = await this.models.verificationToken.create(
TokenType.SignIn, TokenType.SignIn,
email, email,
ttlInSec ttl
); );
const otp = this.crypto.otp(); const otp = this.crypto.otp();
// TODO(@forehalo): this is a temporary solution, we should not rely on cache to store the otp // TODO(@forehalo): this is a temporary solution, we should not rely on cache to store the otp
const cacheKey = OTP_CACHE_KEY(otp); const cacheKey = OTP_CACHE_KEY(otp);
await this.cache.set( await this.cache.set(cacheKey, { token, clientNonce }, { ttl });
cacheKey,
{ token, clientNonce },
{ ttl: ttlInSec * 1000 }
);
const magicLink = this.url.link(callbackUrl, { const magicLink = this.url.link(callbackUrl, {
token: otp, token: otp,

View File

@@ -2,7 +2,7 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import type { CookieOptions, Request, Response } from 'express'; import type { CookieOptions, Request, Response } from 'express';
import { assign, pick } from 'lodash-es'; import { assign, pick } from 'lodash-es';
import { Config, SignUpForbidden } from '../../base'; import { Config, type Duration, SignUpForbidden } from '../../base';
import { Models, type User, type UserSession } from '../../models'; import { Models, type User, type UserSession } from '../../models';
import { FeatureService } from '../features'; import { FeatureService } from '../features';
import { Mailer } from '../mail/mailer'; import { Mailer } from '../mail/mailer';
@@ -128,7 +128,7 @@ export class AuthService implements OnApplicationBootstrap {
return await this.models.session.findUserSessionsBySessionId(sessionId); return await this.models.session.findUserSessionsBySessionId(sessionId);
} }
async createUserSession(userId: string, sessionId?: string, ttl?: number) { async createUserSession(userId: string, sessionId?: string, ttl?: Duration) {
return await this.models.session.createOrRefreshUserSession( return await this.models.session.createOrRefreshUserSession(
userId, userId,
sessionId, sessionId,
@@ -157,7 +157,7 @@ export class AuthService implements OnApplicationBootstrap {
async refreshUserSessionIfNeeded( async refreshUserSessionIfNeeded(
res: Response, res: Response,
userSession: UserSession, userSession: UserSession,
ttr?: number ttr?: Duration
): Promise<boolean> { ): Promise<boolean> {
const newExpiresAt = await this.models.session.refreshUserSessionIfNeeded( const newExpiresAt = await this.models.session.refreshUserSessionIfNeeded(
userSession, userSession,

View File

@@ -4,6 +4,7 @@ import { chunk } from 'lodash-es';
import { import {
DocHistoryNotFound, DocHistoryNotFound,
DocNotFound, DocNotFound,
Due,
EventBus, EventBus,
FailedToSaveUpdates, FailedToSaveUpdates,
FailedToUpsertSnapshot, FailedToUpsertSnapshot,
@@ -251,7 +252,8 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
force || force ||
// last history created before interval in configs // last history created before interval in configs
lastHistoryTimestamp < lastHistoryTimestamp <
snapshot.timestamp - this.options.historyMinInterval(snapshot.spaceId) snapshot.timestamp -
Due.ms(this.options.historyMinInterval(snapshot.spaceId))
) { ) {
shouldCreateHistory = true; shouldCreateHistory = true;
} }

View File

@@ -4,7 +4,7 @@ declare global {
interface AppConfigSchema { interface AppConfigSchema {
doc: { doc: {
history: { history: {
interval: number; interval: string;
}; };
experimental: { experimental: {
yocto: boolean; yocto: boolean;
@@ -20,6 +20,6 @@ defineModuleConfig('doc', {
}, },
'history.interval': { 'history.interval': {
desc: 'The minimum time interval in milliseconds of creating a new history snapshot when doc get updated.', desc: 'The minimum time interval in milliseconds of creating a new history snapshot when doc get updated.',
default: 1000 * 60 * 10 /* 10 mins */, default: '10m',
}, },
}); });

View File

@@ -25,8 +25,6 @@ import {
import { PgWorkspaceDocStorageAdapter } from './adapters/workspace'; import { PgWorkspaceDocStorageAdapter } from './adapters/workspace';
import { type DocDiff, type DocRecord } from './storage'; import { type DocDiff, type DocRecord } from './storage';
const DOC_CONTENT_CACHE_7_DAYS = 7 * 24 * 60 * 60 * 1000;
export interface WorkspaceDocInfo { export interface WorkspaceDocInfo {
id: string; id: string;
name: string; name: string;
@@ -90,7 +88,7 @@ export abstract class DocReader {
const content = await this.getDocContentWithoutCache(workspaceId, docId); const content = await this.getDocContentWithoutCache(workspaceId, docId);
if (content) { if (content) {
await this.cache.set(cacheKey, content, { await this.cache.set(cacheKey, content, {
ttl: DOC_CONTENT_CACHE_7_DAYS, ttl: '7d',
}); });
} }
return content; return content;

View File

@@ -8,7 +8,7 @@ import {
createTestingModule, createTestingModule,
type TestingModule, type TestingModule,
} from '../../../__tests__/utils'; } from '../../../__tests__/utils';
import { NotificationNotFound } from '../../../base'; import { Due, NotificationNotFound } from '../../../base';
import { import {
DocMode, DocMode,
MentionNotificationBody, MentionNotificationBody,
@@ -381,7 +381,7 @@ test('should clean expired notifications', async t => {
// wait for 100 days // wait for 100 days
mock.timers.enable({ mock.timers.enable({
apis: ['Date'], apis: ['Date'],
now: Date.now() + 1000 * 60 * 60 * 24 * 100, now: Due.after('100d'),
}); });
await t.context.models.notification.cleanExpiredNotifications(); await t.context.models.notification.cleanExpiredNotifications();
count = await notificationService.countByUserId(member.id); count = await notificationService.countByUserId(member.id);
@@ -390,7 +390,7 @@ test('should clean expired notifications', async t => {
// wait for 1 year // wait for 1 year
mock.timers.enable({ mock.timers.enable({
apis: ['Date'], apis: ['Date'],
now: Date.now() + 1000 * 60 * 60 * 24 * 365, now: Due.after('1y'),
}); });
await t.context.models.notification.cleanExpiredNotifications(); await t.context.models.notification.cleanExpiredNotifications();
count = await notificationService.countByUserId(member.id); count = await notificationService.countByUserId(member.id);

View File

@@ -1,4 +1,4 @@
import { OneDay, OneKB } from '../../base'; import { Due, OneKB } from '../../base';
export const ByteUnit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; export const ByteUnit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
@@ -14,6 +14,7 @@ export function formatSize(bytes: number, decimals: number = 2): string {
); );
} }
const ONE_DAY_IN_MS = Due.ms('1d');
export function formatDate(ms: number): string { export function formatDate(ms: number): string {
return `${(ms / OneDay).toFixed(0)} days`; return `${(ms / ONE_DAY_IN_MS).toFixed(0)} days`;
} }

View File

@@ -403,8 +403,7 @@ export class WorkspaceDocResolver {
const allowed = await this.cache.setnx( const allowed = await this.cache.setnx(
`fixingOwner:${workspaceId}:${docId}`, `fixingOwner:${workspaceId}:${docId}`,
1, 1,
// TODO(@forehalo): we definitely need a timer helper { ttl: '1d' }
{ ttl: 1000 * 60 * 60 * 24 }
); );
// fixed by other instance // fixed by other instance

View File

@@ -174,13 +174,11 @@ export class InviteResult {
error?: object; error?: object;
} }
const Day = 24 * 60 * 60 * 1000;
export enum WorkspaceInviteLinkExpireTime { export enum WorkspaceInviteLinkExpireTime {
OneDay = Day, OneDay = '1d',
ThreeDays = 3 * Day, ThreeDays = '3d',
OneWeek = 7 * Day, OneWeek = '1w',
OneMonth = 30 * Day, OneMonth = '1M',
} }
registerEnumType(WorkspaceInviteLinkExpireTime, { registerEnumType(WorkspaceInviteLinkExpireTime, {

View File

@@ -4,6 +4,7 @@ import { mock } from 'node:test';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import { createTestingModule, type TestingModule } from '../../__tests__/utils'; import { createTestingModule, type TestingModule } from '../../__tests__/utils';
import { Due } from '../../base';
import { Config } from '../../base/config'; import { Config } from '../../base/config';
import { import {
DocMode, DocMode,
@@ -259,7 +260,7 @@ test('should clean expired notifications', async t => {
// wait for 1 year // wait for 1 year
mock.timers.enable({ mock.timers.enable({
apis: ['Date'], apis: ['Date'],
now: Date.now() + 1000 * 60 * 60 * 24 * 365, now: Due.after('1y'),
}); });
count = await t.context.models.notification.cleanExpiredNotifications(); count = await t.context.models.notification.cleanExpiredNotifications();
t.is(count, 1); t.is(count, 1);

View File

@@ -1,6 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { OneDay, OneGB, OneMB } from '../../base'; import { Due, OneGB, OneMB } from '../../base';
const UserPlanQuotaConfig = z.object({ const UserPlanQuotaConfig = z.object({
// quota name // quota name
@@ -104,7 +104,7 @@ export const FeatureConfigs: {
blobLimit: 10 * OneMB, blobLimit: 10 * OneMB,
businessBlobLimit: 100 * OneMB, businessBlobLimit: 100 * OneMB,
storageQuota: 10 * OneGB, storageQuota: 10 * OneGB,
historyPeriod: 7 * OneDay, historyPeriod: Due.ms('7d'),
memberLimit: 3, memberLimit: 3,
copilotActionLimit: 10, copilotActionLimit: 10,
}, },
@@ -116,7 +116,7 @@ export const FeatureConfigs: {
name: 'Pro', name: 'Pro',
blobLimit: 100 * OneMB, blobLimit: 100 * OneMB,
storageQuota: 100 * OneGB, storageQuota: 100 * OneGB,
historyPeriod: 30 * OneDay, historyPeriod: Due.ms('30d'),
memberLimit: 10, memberLimit: 10,
copilotActionLimit: 10, copilotActionLimit: 10,
}, },
@@ -128,7 +128,7 @@ export const FeatureConfigs: {
name: 'Lifetime Pro', name: 'Lifetime Pro',
blobLimit: 100 * OneMB, blobLimit: 100 * OneMB,
storageQuota: 1024 * OneGB, storageQuota: 1024 * OneGB,
historyPeriod: 30 * OneDay, historyPeriod: Due.ms('30d'),
memberLimit: 10, memberLimit: 10,
copilotActionLimit: 10, copilotActionLimit: 10,
}, },
@@ -141,7 +141,7 @@ export const FeatureConfigs: {
blobLimit: 500 * OneMB, blobLimit: 500 * OneMB,
storageQuota: 100 * OneGB, storageQuota: 100 * OneGB,
seatQuota: 20 * OneGB, seatQuota: 20 * OneGB,
historyPeriod: 30 * OneDay, historyPeriod: Due.ms('30d'),
memberLimit: 1, memberLimit: 1,
}, },
}, },

View File

@@ -7,7 +7,7 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
import { PaginationInput } from '../base'; import { Due, PaginationInput } from '../base';
import { BaseModel } from './base'; import { BaseModel } from './base';
import { DocMode } from './common'; import { DocMode } from './common';
@@ -15,8 +15,6 @@ export { NotificationLevel, NotificationType };
export type { Notification }; export type { Notification };
// #region input // #region input
export const ONE_YEAR = 1000 * 60 * 60 * 24 * 365;
const IdSchema = z.string().trim().min(1).max(100); const IdSchema = z.string().trim().min(1).max(100);
export const BaseNotificationCreateSchema = z.object({ export const BaseNotificationCreateSchema = z.object({
@@ -237,7 +235,7 @@ export class NotificationModel extends BaseModel {
async cleanExpiredNotifications() { async cleanExpiredNotifications() {
const { count } = await this.db.notification.deleteMany({ const { count } = await this.db.notification.deleteMany({
// delete notifications that are older than one year // delete notifications that are older than one year
where: { createdAt: { lte: new Date(Date.now() - ONE_YEAR) } }, where: { createdAt: { lte: Due.before('1y') } },
}); });
if (count > 0) { if (count > 0) {
this.logger.log(`Deleted ${count} expired notifications`); this.logger.log(`Deleted ${count} expired notifications`);

View File

@@ -6,7 +6,7 @@ import {
type UserSession, type UserSession,
} from '@prisma/client'; } from '@prisma/client';
import { Config } from '../base'; import { Config, Due, Duration } from '../base';
import { BaseModel } from './base'; import { BaseModel } from './base';
export type { Session, UserSession }; export type { Session, UserSession };
@@ -46,7 +46,7 @@ export class SessionModel extends BaseModel {
async createOrRefreshUserSession( async createOrRefreshUserSession(
userId: string, userId: string,
sessionId?: string, sessionId?: string,
ttl = this.config.auth.session.ttl ttl: Duration = this.config.auth.session.ttl
) { ) {
// check whether given session is valid // check whether given session is valid
if (sessionId) { if (sessionId) {
@@ -66,7 +66,7 @@ export class SessionModel extends BaseModel {
sessionId = session.id; sessionId = session.id;
} }
const expiresAt = new Date(Date.now() + ttl * 1000); const expiresAt = Due.after(ttl);
return await this.db.userSession.upsert({ return await this.db.userSession.upsert({
where: { where: {
sessionId_userId: { sessionId_userId: {
@@ -87,19 +87,17 @@ export class SessionModel extends BaseModel {
async refreshUserSessionIfNeeded( async refreshUserSessionIfNeeded(
userSession: UserSession, userSession: UserSession,
ttr = this.config.auth.session.ttr ttr: Duration = this.config.auth.session.ttr
): Promise<Date | undefined> { ): Promise<Date | undefined> {
if ( if (
userSession.expiresAt && userSession.expiresAt &&
userSession.expiresAt.getTime() - Date.now() > ttr * 1000 Due.before(ttr, userSession.expiresAt) > new Date()
) { ) {
// no need to refresh // no need to refresh
return; return;
} }
const newExpiresAt = new Date( const newExpiresAt = Due.after(this.config.auth.session.ttl);
Date.now() + this.config.auth.session.ttl * 1000
);
await this.db.userSession.update({ await this.db.userSession.update({
where: { where: {
id: userSession.id, id: userSession.id,

View File

@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { type VerificationToken } from '@prisma/client'; import { type VerificationToken } from '@prisma/client';
import { Due, Duration } from '../base';
import { CryptoHelper } from '../base/helpers'; import { CryptoHelper } from '../base/helpers';
import { BaseModel } from './base'; import { BaseModel } from './base';
@@ -25,18 +26,14 @@ export class VerificationTokenModel extends BaseModel {
/** /**
* create token by type and credential (optional) with ttl in seconds (default 30 minutes) * create token by type and credential (optional) with ttl in seconds (default 30 minutes)
*/ */
async create( async create(type: TokenType, credential?: string, ttl: Duration = '30m') {
type: TokenType,
credential?: string,
ttlInSec: number = 30 * 60
) {
const plaintextToken = randomUUID(); const plaintextToken = randomUUID();
const { token } = await this.db.verificationToken.create({ const { token } = await this.db.verificationToken.create({
data: { data: {
type, type,
token: plaintextToken, token: plaintextToken,
credential, credential,
expiresAt: new Date(Date.now() + ttlInSec * 1000), expiresAt: Due.after(ttl),
}, },
}); });
return this.crypto.encrypt(token); return this.crypto.encrypt(token);

View File

@@ -78,7 +78,7 @@ export class CaptchaService {
const challenge = await this.models.verificationToken.create( const challenge = await this.models.verificationToken.create(
TokenType.Challenge, TokenType.Challenge,
resource, resource,
5 * 60 '5m'
); );
return { return {

View File

@@ -6,7 +6,6 @@ import { SessionCache } from '../../base';
import { SubmittedMessage, SubmittedMessageSchema } from './types'; import { SubmittedMessage, SubmittedMessageSchema } from './types';
const CHAT_MESSAGE_KEY = 'chat-message'; const CHAT_MESSAGE_KEY = 'chat-message';
const CHAT_MESSAGE_TTL = 3600 * 1 * 1000; // 1 hours
@Injectable() @Injectable()
export class ChatMessageCache { export class ChatMessageCache {
@@ -20,7 +19,7 @@ export class ChatMessageCache {
const parsedMessage = SubmittedMessageSchema.parse(message); const parsedMessage = SubmittedMessageSchema.parse(message);
const id = randomUUID(); const id = randomUUID();
await this.cache.set(`${CHAT_MESSAGE_KEY}:${id}`, parsedMessage, { await this.cache.set(`${CHAT_MESSAGE_KEY}:${id}`, parsedMessage, {
ttl: CHAT_MESSAGE_TTL, ttl: '1h',
}); });
return id; return id;
} }

View File

@@ -6,7 +6,7 @@ import Sinon from 'sinon';
import { createModule } from '../../../__tests__/create-module'; import { createModule } from '../../../__tests__/create-module';
import { Mockers } from '../../../__tests__/mocks'; import { Mockers } from '../../../__tests__/mocks';
import { JOB_SIGNAL } from '../../../base'; import { Due, JOB_SIGNAL } from '../../../base';
import { ConfigModule } from '../../../base/config'; import { ConfigModule } from '../../../base/config';
import { ServerConfigModule } from '../../../core/config'; import { ServerConfigModule } from '../../../core/config';
import { Models } from '../../../models'; import { Models } from '../../../models';
@@ -160,7 +160,7 @@ test('should not index workspace if it is not updated in 180 days', async t => {
user, user,
workspaceId: workspace.id, workspaceId: workspace.id,
docId: workspace.id, docId: workspace.id,
updatedAt: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000 - 1), updatedAt: Due.before('181d'),
}); });
const count = module.queue.count('indexer.indexWorkspace'); const count = module.queue.count('indexer.indexWorkspace');

View File

@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Config, JOB_SIGNAL, JobQueue, OnJob } from '../../base'; import { Config, Due, JOB_SIGNAL, JobQueue, OnJob } from '../../base';
import { readAllDocIdsFromWorkspaceSnapshot } from '../../core/utils/blocksuite'; import { readAllDocIdsFromWorkspaceSnapshot } from '../../core/utils/blocksuite';
import { Models } from '../../models'; import { Models } from '../../models';
import { IndexerService } from './service'; import { IndexerService } from './service';
@@ -182,8 +182,7 @@ export class IndexerJob {
// ignore 180 days not updated workspaces // ignore 180 days not updated workspaces
if ( if (
!snapshotMeta?.updatedAt || !snapshotMeta?.updatedAt ||
Date.now() - snapshotMeta.updatedAt.getTime() > snapshotMeta.updatedAt < Due.before('180d')
180 * 24 * 60 * 60 * 1000
) { ) {
continue; continue;
} }

View File

@@ -7,6 +7,7 @@ import { z } from 'zod';
import { import {
CryptoHelper, CryptoHelper,
Due,
EventBus, EventBus,
InternalServerError, InternalServerError,
InvalidLicenseToActivate, InvalidLicenseToActivate,
@@ -337,7 +338,7 @@ export class LicenseService {
const licenses = await this.db.installedLicense.findMany({ const licenses = await this.db.installedLicense.findMany({
where: { where: {
validatedAt: { validatedAt: {
lte: new Date(Date.now() - 1000 * 60 * 60 /* 1h */), lte: Due.before('1h'),
}, },
}, },
}); });

View File

@@ -29,7 +29,7 @@ export class OAuthService {
async saveOAuthState(state: OAuthState) { async saveOAuthState(state: OAuthState) {
const token = randomUUID(); const token = randomUUID();
await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, state, { await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, state, {
ttl: 3600 * 3 * 1000 /* 3 hours */, ttl: '3h',
}); });
return token; return token;

View File

@@ -26,9 +26,6 @@ import {
} from './utils'; } from './utils';
import { decodeWithCharset } from './utils/encoding'; import { decodeWithCharset } from './utils/encoding';
// cache for 30 minutes
const CACHE_TTL = 1000 * 60 * 30;
@Public() @Public()
@UseNamedGuard('selfhost') @UseNamedGuard('selfhost')
@Controller('/api/worker') @Controller('/api/worker')
@@ -98,7 +95,7 @@ export class WorkerController {
if (contentType?.startsWith('image/')) { if (contentType?.startsWith('image/')) {
const buffer = Buffer.from(await response.arrayBuffer()); const buffer = Buffer.from(await response.arrayBuffer());
await this.cache.set(cachedUrl, buffer.toString('base64'), { await this.cache.set(cachedUrl, buffer.toString('base64'), {
ttl: CACHE_TTL, ttl: '30m',
}); });
const contentDisposition = response.headers.get('Content-Disposition'); const contentDisposition = response.headers.get('Content-Disposition');
return resp return resp
@@ -118,7 +115,7 @@ export class WorkerController {
if (response.status >= 400 && response.status < 500) { if (response.status >= 400 && response.status < 500) {
// rejected by server, cache a empty response // rejected by server, cache a empty response
await this.cache.set(cachedUrl, Buffer.from([]).toString('base64'), { await this.cache.set(cachedUrl, Buffer.from([]).toString('base64'), {
ttl: CACHE_TTL, ttl: '30m',
}); });
} }
this.logger.error('Failed to fetch image', { this.logger.error('Failed to fetch image', {
@@ -302,7 +299,7 @@ export class WorkerController {
responseSize: json.length, responseSize: json.length,
}); });
await this.cache.set(cachedUrl, res, { ttl: CACHE_TTL }); await this.cache.set(cachedUrl, res, { ttl: '30m' });
return resp return resp
.status(200) .status(200)
.header({ .header({