mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 02:00:49 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4c9e3c36d | |||
| 1a8d884f8e | |||
| 91acb88a2d | |||
| 43704d60fb | |||
| 46e7e35357 | |||
| b98ab495bb | |||
| 99b07c2ee1 |
@@ -62,6 +62,18 @@
|
||||
"concurrency": 10
|
||||
}
|
||||
},
|
||||
"queues.calendar": {
|
||||
"type": "object",
|
||||
"description": "The config for calendar job queue\n@default {\"concurrency\":4}",
|
||||
"properties": {
|
||||
"concurrency": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"concurrency": 4
|
||||
}
|
||||
},
|
||||
"queues.doc": {
|
||||
"type": "object",
|
||||
"description": "The config for doc job queue\n@default {\"concurrency\":1}",
|
||||
@@ -843,7 +855,7 @@
|
||||
"properties": {
|
||||
"google": {
|
||||
"type": "object",
|
||||
"description": "Google Calendar integration config\n@default {\"enabled\":false,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\"}\n@link https://developers.google.com/calendar/api/guides/push",
|
||||
"description": "Google Calendar integration config\n@default {\"enabled\":false,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\",\"requestTimeoutMs\":10000}\n@link https://developers.google.com/calendar/api/guides/push",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
@@ -859,6 +871,9 @@
|
||||
},
|
||||
"webhookVerificationToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestTimeoutMs": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
@@ -866,7 +881,8 @@
|
||||
"clientId": "",
|
||||
"clientSecret": "",
|
||||
"externalWebhookUrl": "",
|
||||
"webhookVerificationToken": ""
|
||||
"webhookVerificationToken": "",
|
||||
"requestTimeoutMs": 10000
|
||||
}
|
||||
},
|
||||
"caldav": {
|
||||
|
||||
@@ -48,6 +48,7 @@ testem.log
|
||||
/typings
|
||||
tsconfig.tsbuildinfo
|
||||
.context
|
||||
*.md
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE
|
||||
"calendar_subscriptions"
|
||||
ADD
|
||||
COLUMN "next_sync_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD
|
||||
COLUMN "sync_retry_count" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE
|
||||
"calendar_subscriptions" AS s
|
||||
SET
|
||||
"next_sync_at" = CASE
|
||||
WHEN s."last_sync_at" IS NULL THEN CURRENT_TIMESTAMP
|
||||
ELSE s."last_sync_at" + make_interval(
|
||||
mins => COALESCE(a."refresh_interval_minutes", 30)
|
||||
)
|
||||
END
|
||||
FROM
|
||||
"calendar_accounts" AS a
|
||||
WHERE
|
||||
a."id" = s."account_id";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "calendar_subscriptions_custom_channel_id_idx" ON "calendar_subscriptions"("custom_channel_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "calendar_subscriptions_enabled_next_sync_at_idx" ON "calendar_subscriptions"("enabled", "next_sync_at");
|
||||
@@ -110,7 +110,7 @@
|
||||
"react-dom": "19.2.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"semver": "^7.7.3",
|
||||
"semver": "^7.7.4",
|
||||
"ses": "^1.14.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"stripe": "^17.7.0",
|
||||
@@ -138,8 +138,7 @@
|
||||
"@types/nodemailer": "^7.0.0",
|
||||
"@types/on-headers": "^1.0.3",
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/sinon": "^21.0.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"ava": "^6.4.0",
|
||||
|
||||
@@ -1037,6 +1037,8 @@ model CalendarSubscription {
|
||||
enabled Boolean @default(true)
|
||||
syncToken String? @map("sync_token") @db.Text
|
||||
lastSyncAt DateTime? @map("last_sync_at") @db.Timestamptz(3)
|
||||
nextSyncAt DateTime @default(now()) @map("next_sync_at") @db.Timestamptz(3)
|
||||
syncRetryCount Int @default(0) @map("sync_retry_count")
|
||||
customChannelId String? @map("custom_channel_id") @db.VarChar
|
||||
customResourceId String? @map("custom_resource_id") @db.VarChar
|
||||
channelExpiration DateTime? @map("channel_expiration") @db.Timestamptz(3)
|
||||
@@ -1050,6 +1052,8 @@ model CalendarSubscription {
|
||||
@@unique([accountId, externalCalendarId])
|
||||
@@index([accountId])
|
||||
@@index([provider, externalCalendarId])
|
||||
@@index([customChannelId])
|
||||
@@index([enabled, nextSyncAt])
|
||||
@@map("calendar_subscriptions")
|
||||
}
|
||||
|
||||
|
||||
@@ -858,7 +858,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> test@test.com invited you to join Test Workspace
|
||||
> You were invited to join a workspace on AFFiNE
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -973,7 +973,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> test@test.com accepted your invitation
|
||||
> Your workspace invitation was accepted
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -1072,7 +1072,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> test@test.com left Test Workspace
|
||||
> A workspace member left
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -1132,7 +1132,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> New request to join Test Workspace
|
||||
> New request to join a workspace
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -1239,7 +1239,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> Your request to join Test Workspace has been approved
|
||||
> Your request to join a workspace has been approved
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -1294,7 +1294,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> Your request to join Test Workspace was declined
|
||||
> Your request to join a workspace was declined
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -1348,7 +1348,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> You have been removed from Test Workspace
|
||||
> You have been removed from a workspace
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -1401,7 +1401,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> Your ownership of Test Workspace has been transferred
|
||||
> Your workspace ownership has been transferred
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -1454,7 +1454,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> You are now the owner of Test Workspace
|
||||
> You are now the owner of a workspace
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -1506,7 +1506,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> test@test.com mentioned you in Test Doc
|
||||
> You were mentioned in AFFiNE
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<!--$-->␊
|
||||
@@ -1601,7 +1601,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> test@test.com commented on Test Doc
|
||||
> New comment in AFFiNE
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<!--$-->␊
|
||||
@@ -1695,7 +1695,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> test@test.com mentioned you in a comment on Test Doc
|
||||
> You were mentioned in a comment
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<!--$-->␊
|
||||
@@ -1894,7 +1894,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> You are now an admin of Test Workspace
|
||||
> You are now a workspace admin
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -1993,7 +1993,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> Your role has been changed in Test Workspace
|
||||
> Your workspace role has been changed
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -2094,7 +2094,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> [Action Required] Final warning: Your workspace Test Workspace will be deleted in 24 hours
|
||||
> [Action Required] Final warning: Your workspace will be deleted in 24 hours
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -2208,7 +2208,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> [Action Required] Important: Your workspace Test Workspace will be deleted soon
|
||||
> [Action Required] Important: Your workspace will be deleted soon
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -2324,7 +2324,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> Your workspace Test Workspace has been deleted
|
||||
> Your workspace has been deleted
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -2408,7 +2408,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> [Action Required] Your Test Workspace team workspace will expire soon
|
||||
> [Action Required] Your team workspace will expire soon
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -2511,7 +2511,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> Your Test Workspace team workspace has expired
|
||||
> Your team workspace has expired
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -2689,7 +2689,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
|
||||
## should render mention email with empty doc title
|
||||
|
||||
> test@test.com mentioned you in
|
||||
> You were mentioned in AFFiNE
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<!--$-->␊
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,6 @@
|
||||
import test from 'ava';
|
||||
|
||||
import { normalizeSMTPHeloHostname } from '../core/mail/utils';
|
||||
import { Renderers } from '../mails';
|
||||
import { TEST_DOC, TEST_USER } from '../mails/common';
|
||||
|
||||
@@ -21,3 +22,23 @@ test('should render mention email with empty doc title', async t => {
|
||||
});
|
||||
t.snapshot(content.html, content.subject);
|
||||
});
|
||||
|
||||
test('should normalize valid SMTP HELO hostnames', t => {
|
||||
t.is(normalizeSMTPHeloHostname('mail.example.com'), 'mail.example.com');
|
||||
t.is(normalizeSMTPHeloHostname(' localhost '), 'localhost');
|
||||
t.is(normalizeSMTPHeloHostname('[127.0.0.1]'), '[127.0.0.1]');
|
||||
t.is(normalizeSMTPHeloHostname('[IPv6:2001:db8::1]'), '[IPv6:2001:db8::1]');
|
||||
});
|
||||
|
||||
test('should reject invalid SMTP HELO hostnames', t => {
|
||||
t.is(normalizeSMTPHeloHostname(), undefined);
|
||||
t.is(normalizeSMTPHeloHostname(''), undefined);
|
||||
t.is(normalizeSMTPHeloHostname(' '), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('AFFiNE Server'), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('-example.com'), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('example-.com'), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('example..com'), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('[bad host]'), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('[foo]'), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('[IPv6:foo]'), undefined);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { JobQueue } from '../../base';
|
||||
export class MockJobQueue {
|
||||
add = Sinon.createStubInstance(JobQueue).add.resolves();
|
||||
remove = Sinon.createStubInstance(JobQueue).remove.resolves();
|
||||
removeWhere = Sinon.createStubInstance(JobQueue).removeWhere.resolves([]);
|
||||
|
||||
last<Job extends JobName>(name: Job): { name: Job; payload: Jobs[Job] } {
|
||||
const addJobName = this.add.lastCall?.args[0];
|
||||
|
||||
@@ -437,6 +437,37 @@ test('should throw if user has subscription already', async t => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should allow checkout after local subscription period ended', async t => {
|
||||
const { service, u1, db, stripe } = t.context;
|
||||
|
||||
await db.subscription.create({
|
||||
data: {
|
||||
targetId: u1.id,
|
||||
stripeSubscriptionId: 'sub_expired_ai',
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date('2026-05-04T13:11:45.000Z'),
|
||||
end: new Date('2026-05-11T13:11:45.000Z'),
|
||||
},
|
||||
});
|
||||
|
||||
await service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
successCallbackLink: '',
|
||||
},
|
||||
{ user: u1 }
|
||||
);
|
||||
|
||||
t.true(stripe.checkout.sessions.create.calledOnce);
|
||||
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
|
||||
price: AI_YEARLY,
|
||||
coupon: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('should get correct pro plan price for checking out', async t => {
|
||||
const { app, service, u1, stripe, feature } = t.context;
|
||||
// non-ea user
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AppModule } from '../app.module';
|
||||
import {
|
||||
CANARY_CLIENT_VERSION_MAX_AGE_DAYS,
|
||||
ConfigFactory,
|
||||
hasNewerVersion,
|
||||
UseNamedGuard,
|
||||
} from '../base';
|
||||
import { Public } from '../core/auth/guard';
|
||||
@@ -249,3 +250,11 @@ test('should reject old canary date version in canary namespace', async t => {
|
||||
env.NAMESPACE = prevNamespace;
|
||||
}
|
||||
});
|
||||
|
||||
test('should compare release versions for available upgrades', t => {
|
||||
t.false(hasNewerVersion('0.26.5', '0.26.4'));
|
||||
t.false(hasNewerVersion('0.26.5', '0.26.5'));
|
||||
t.true(hasNewerVersion('0.26.5', '0.26.6'));
|
||||
t.true(hasNewerVersion('0.26.5', '0.26.6-beta.1'));
|
||||
t.false(hasNewerVersion('0.26.6-beta.2', '0.26.6-beta.1'));
|
||||
});
|
||||
|
||||
@@ -117,6 +117,22 @@ test('should remove job from queue', async t => {
|
||||
t.is(nullData, undefined);
|
||||
t.is(nullJob, undefined);
|
||||
});
|
||||
|
||||
test('should remove jobs by payload predicate', async t => {
|
||||
const keep = await queue.add('nightly.__test__job', { name: 'keep' });
|
||||
const remove = await queue.add('nightly.__test__job', { name: 'remove' });
|
||||
const other = await queue.add('nightly.__test__job2', { name: 'remove' });
|
||||
|
||||
const removed = await queue.removeWhere(
|
||||
'nightly.__test__job',
|
||||
job => job.name === 'remove'
|
||||
);
|
||||
|
||||
t.deepEqual(removed, [{ name: 'remove' }]);
|
||||
t.truthy(await queue.get(keep.id!, 'nightly.__test__job'));
|
||||
t.is(await queue.get(remove.id!, 'nightly.__test__job'), undefined);
|
||||
t.truthy(await queue.get(other.id!, 'nightly.__test__job2'));
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region executor
|
||||
|
||||
@@ -55,6 +55,14 @@ defineModuleConfig('job', {
|
||||
schema,
|
||||
},
|
||||
|
||||
'queues.calendar': {
|
||||
desc: 'The config for calendar job queue',
|
||||
default: {
|
||||
concurrency: 4,
|
||||
},
|
||||
schema,
|
||||
},
|
||||
|
||||
'queues.doc': {
|
||||
desc: 'The config for doc job queue',
|
||||
default: {
|
||||
|
||||
@@ -28,6 +28,7 @@ export enum Queue {
|
||||
DOC = 'doc',
|
||||
COPILOT = 'copilot',
|
||||
INDEXER = 'indexer',
|
||||
CALENDAR = 'calendar',
|
||||
}
|
||||
|
||||
export const QUEUES = Object.values(Queue);
|
||||
|
||||
@@ -55,6 +55,39 @@ export class JobQueue {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async removeWhere<T extends JobName>(
|
||||
jobName: T,
|
||||
predicate: (payload: Jobs[T]) => boolean | Promise<boolean>
|
||||
): Promise<Jobs[T][]> {
|
||||
const ns = namespace(jobName);
|
||||
const queue = this.getQueue(ns);
|
||||
const jobs = (await queue.getJobs([
|
||||
'waiting',
|
||||
'delayed',
|
||||
'prioritized',
|
||||
'paused',
|
||||
'waiting-children',
|
||||
])) as Job<JobData<T>>[];
|
||||
const removed: Jobs[T][] = [];
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.name !== jobName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = job.data.payload;
|
||||
if (!(await predicate(payload))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await job.remove();
|
||||
this.logger.log(`Job ${jobName}(id=${job.id}) removed from queue ${ns}`);
|
||||
removed.push(payload);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
async get<T extends JobName>(jobId: string, jobName: T) {
|
||||
const ns = namespace(jobName);
|
||||
const queue = this.getQueue(ns);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import semver from 'semver';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Example: 2026.2.6-canary.015
|
||||
@@ -89,3 +91,26 @@ export function checkCanaryDateClientVersion(
|
||||
normalized: parsed.normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeComparableVersion(version: string): string | null {
|
||||
const canary = parseCanaryDateClientVersion(version);
|
||||
return semver.valid(canary?.normalized ?? version.trim(), {
|
||||
loose: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function hasNewerVersion(
|
||||
currentVersion: string,
|
||||
nextVersion: string
|
||||
): boolean {
|
||||
const current = normalizeComparableVersion(currentVersion);
|
||||
const next = normalizeComparableVersion(nextVersion);
|
||||
|
||||
if (!current || !next) {
|
||||
return currentVersion.trim() !== nextVersion.trim();
|
||||
}
|
||||
|
||||
return semver.gt(next, current, {
|
||||
loose: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@nestjs/graphql';
|
||||
import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
|
||||
|
||||
import { Config, URLHelper } from '../../base';
|
||||
import { Config, hasNewerVersion, URLHelper } from '../../base';
|
||||
import { Namespace } from '../../env';
|
||||
import { Feature, type WorkspaceFeatureName } from '../../models';
|
||||
import { CurrentUser, Public } from '../auth';
|
||||
@@ -143,7 +143,7 @@ export class ServerConfigResolver {
|
||||
}>;
|
||||
|
||||
const latest = releases.at(0);
|
||||
if (!latest || latest.name === env.version) {
|
||||
if (!latest || !hasNewerVersion(env.version, latest.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import test from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { Mockers } from '../../../__tests__/mocks';
|
||||
import { createTestingModule } from '../../../__tests__/utils';
|
||||
import { Cache } from '../../../base';
|
||||
import { Models } from '../../../models';
|
||||
import { MailJob } from '../job';
|
||||
import { MailSender } from '../sender';
|
||||
|
||||
let module: Awaited<ReturnType<typeof createTestingModule>>;
|
||||
let cache: Cache;
|
||||
let mailJob: MailJob;
|
||||
let sender: MailSender;
|
||||
let models: Models;
|
||||
|
||||
test.before(async () => {
|
||||
module = await createTestingModule();
|
||||
cache = module.get(Cache);
|
||||
mailJob = module.get(MailJob);
|
||||
sender = module.get(MailSender);
|
||||
models = module.get(Models);
|
||||
});
|
||||
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
Sinon.restore();
|
||||
});
|
||||
|
||||
test('should clear pending mail records when user is deleted', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const another = await module.create(Mockers.User);
|
||||
const sendMailKey = 'mailjob:sendMail';
|
||||
const retryMailKey = 'mailjob:sendMail:retry';
|
||||
const userKey = `${sendMailKey}:SignIn:${user.email}`;
|
||||
const userRetryKey = `${sendMailKey}:VerifyEmail:${user.email}`;
|
||||
const anotherKey = `${sendMailKey}:SignIn:${another.email}`;
|
||||
|
||||
await cache.mapSet(sendMailKey, userKey, 1);
|
||||
await cache.mapSet(sendMailKey, anotherKey, 1);
|
||||
await cache.mapSet(
|
||||
retryMailKey,
|
||||
userRetryKey,
|
||||
JSON.stringify({
|
||||
startTime: Date.now(),
|
||||
name: 'VerifyEmail',
|
||||
to: user.email,
|
||||
props: { url: 'https://affine.pro/verify' },
|
||||
})
|
||||
);
|
||||
|
||||
await mailJob.onUserDeleted({ ...user, ownedWorkspaces: [] });
|
||||
|
||||
t.true(module.queue.removeWhere.calledOnce);
|
||||
t.is(module.queue.removeWhere.firstCall.args[0], 'notification.sendMail');
|
||||
const shouldRemove = module.queue.removeWhere.firstCall.args[1];
|
||||
t.true(
|
||||
await shouldRemove({
|
||||
to: user.email,
|
||||
} as Jobs['notification.sendMail'])
|
||||
);
|
||||
t.false(
|
||||
await shouldRemove({
|
||||
to: another.email,
|
||||
} as Jobs['notification.sendMail'])
|
||||
);
|
||||
t.is(await cache.mapGet(sendMailKey, userKey), undefined);
|
||||
t.is(await cache.mapGet(retryMailKey, userRetryKey), undefined);
|
||||
t.is(await cache.mapGet(sendMailKey, anotherKey), 1);
|
||||
});
|
||||
|
||||
test('should skip queued mail for disabled recipient', async t => {
|
||||
const user = await module.create(Mockers.User, { disabled: true });
|
||||
const send = Sinon.stub(sender, 'send').resolves(true);
|
||||
|
||||
await mailJob.sendMail({
|
||||
startTime: Date.now(),
|
||||
name: 'SignIn',
|
||||
to: user.email,
|
||||
props: {
|
||||
url: 'https://affine.pro/sign-in',
|
||||
otp: '123456',
|
||||
},
|
||||
});
|
||||
|
||||
t.false(send.called);
|
||||
t.truthy(await models.user.get(user.id, { withDisabled: true }));
|
||||
});
|
||||
|
||||
test('should drop expired mail retry', async t => {
|
||||
const send = Sinon.stub(sender, 'send').resolves(true);
|
||||
|
||||
await mailJob.sendMail({
|
||||
startTime: Date.now() - 25 * 60 * 60 * 1000,
|
||||
name: 'SignIn',
|
||||
to: 'expired-retry@example.com',
|
||||
props: {
|
||||
url: 'https://affine.pro/sign-in',
|
||||
otp: '123456',
|
||||
},
|
||||
});
|
||||
|
||||
t.false(send.called);
|
||||
});
|
||||
|
||||
test('should drop time-sensitive mail after its business expiration', async t => {
|
||||
const send = Sinon.stub(sender, 'send').resolves(true);
|
||||
|
||||
await mailJob.sendMail({
|
||||
startTime: Date.now() - 31 * 60 * 1000,
|
||||
name: 'SignIn',
|
||||
to: 'expired-sign-in@example.com',
|
||||
props: {
|
||||
url: 'https://affine.pro/sign-in',
|
||||
otp: '123456',
|
||||
},
|
||||
});
|
||||
|
||||
t.false(send.called);
|
||||
});
|
||||
|
||||
test('should use explicit mail expiration when provided', async t => {
|
||||
const send = Sinon.stub(sender, 'send').resolves(true);
|
||||
|
||||
await mailJob.sendMail({
|
||||
startTime: Date.now(),
|
||||
expiresAt: Date.now() - 1,
|
||||
name: 'MemberInvitation',
|
||||
to: 'expired-invitation@example.com',
|
||||
props: {
|
||||
user: {
|
||||
$$userId: 'owner-id',
|
||||
},
|
||||
workspace: {
|
||||
$$workspaceId: 'workspace-id',
|
||||
},
|
||||
url: 'https://affine.pro/invite/test',
|
||||
},
|
||||
});
|
||||
|
||||
t.false(send.called);
|
||||
});
|
||||
|
||||
test('should drop mail retry after max attempts', async t => {
|
||||
const send = Sinon.stub(sender, 'send').resolves(true);
|
||||
|
||||
await mailJob.sendMail({
|
||||
startTime: Date.now(),
|
||||
retryCount: 12,
|
||||
name: 'SignIn',
|
||||
to: 'max-retry@example.com',
|
||||
props: {
|
||||
url: 'https://affine.pro/sign-in',
|
||||
otp: '123456',
|
||||
},
|
||||
});
|
||||
|
||||
t.false(send.called);
|
||||
});
|
||||
|
||||
test('should requeue legacy stringified retry mail', async t => {
|
||||
const retryMailKey = 'mailjob:sendMail:retry';
|
||||
const job: Jobs['notification.sendMail'] = {
|
||||
startTime: Date.now(),
|
||||
name: 'SignIn',
|
||||
to: 'legacy-retry@example.com',
|
||||
props: {
|
||||
url: 'https://affine.pro/sign-in',
|
||||
otp: '123456',
|
||||
},
|
||||
};
|
||||
const cacheKey = `${retryMailKey}:SignIn:${job.to}`;
|
||||
|
||||
Sinon.stub(cache, 'mapRandomKey')
|
||||
.onFirstCall()
|
||||
.resolves(cacheKey)
|
||||
.onSecondCall()
|
||||
.resolves(undefined);
|
||||
await cache.mapSet(retryMailKey, cacheKey, JSON.stringify(job));
|
||||
await mailJob.sendRetryMails();
|
||||
|
||||
t.true(module.queue.add.calledWith('notification.sendMail', job));
|
||||
t.is(await cache.mapGet(retryMailKey, cacheKey), undefined);
|
||||
});
|
||||
|
||||
test('should skip member invitation mail when rendered workspace name contains domain', async t => {
|
||||
const owner = await module.create(Mockers.User);
|
||||
const member = await module.create(Mockers.User);
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
name: 'BTC https://spam.example',
|
||||
});
|
||||
const send = Sinon.stub(sender, 'send').resolves(true);
|
||||
|
||||
await mailJob.sendMail({
|
||||
startTime: Date.now(),
|
||||
name: 'MemberInvitation',
|
||||
to: member.email,
|
||||
props: {
|
||||
user: {
|
||||
$$userId: owner.id,
|
||||
},
|
||||
workspace: {
|
||||
$$workspaceId: workspace.id,
|
||||
},
|
||||
url: 'https://affine.pro/invite/test',
|
||||
},
|
||||
});
|
||||
|
||||
t.false(send.called);
|
||||
});
|
||||
|
||||
test('should keep dynamic mail props untouched for retry', async t => {
|
||||
const owner = await module.create(Mockers.User);
|
||||
const member = await module.create(Mockers.User);
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
name: 'Safe Workspace',
|
||||
});
|
||||
Sinon.stub(sender, 'send').resolves(false);
|
||||
const job: Jobs['notification.sendMail'] = {
|
||||
startTime: Date.now(),
|
||||
name: 'MemberInvitation',
|
||||
to: member.email,
|
||||
props: {
|
||||
user: {
|
||||
$$userId: owner.id,
|
||||
},
|
||||
workspace: {
|
||||
$$workspaceId: workspace.id,
|
||||
},
|
||||
url: 'https://affine.pro/invite/test',
|
||||
},
|
||||
};
|
||||
|
||||
await mailJob.sendMail(job);
|
||||
|
||||
t.deepEqual(job.props.user, { $$userId: owner.id });
|
||||
t.deepEqual(job.props.workspace, { $$workspaceId: workspace.id });
|
||||
});
|
||||
@@ -2,12 +2,13 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { getStreamAsBuffer } from 'get-stream';
|
||||
|
||||
import { Cache, JOB_SIGNAL, JobQueue, OnJob, sleep } from '../../base';
|
||||
import { Cache, JOB_SIGNAL, JobQueue, OnEvent, OnJob, sleep } from '../../base';
|
||||
import { type MailName, MailProps, Renderers } from '../../mails';
|
||||
import { UserProps, WorkspaceProps } from '../../mails/components';
|
||||
import { Models } from '../../models';
|
||||
import { DocReader } from '../doc/reader';
|
||||
import { WorkspaceBlobStorage } from '../storage';
|
||||
import { containsUrlOrDomain } from '../workspaces/abuse';
|
||||
import { MailSender, SendOptions } from './sender';
|
||||
|
||||
type DynamicallyFetchedProps<Props> = {
|
||||
@@ -35,7 +36,11 @@ type SendMailJob<Mail extends MailName = MailName, Props = MailProps<Mail>> = {
|
||||
|
||||
declare global {
|
||||
interface Jobs {
|
||||
'notification.sendMail': { startTime: number } & {
|
||||
'notification.sendMail': {
|
||||
startTime: number;
|
||||
retryCount?: number;
|
||||
expiresAt?: number;
|
||||
} & {
|
||||
[K in MailName]: SendMailJob<K>;
|
||||
}[MailName];
|
||||
}
|
||||
@@ -47,6 +52,19 @@ const sendMailCacheKey = (name: string, to: string) =>
|
||||
`${sendMailKey}:${name}:${to}`;
|
||||
const retryMaxPerTick = 20;
|
||||
const retryFirstTime = 3;
|
||||
const retryMaxAttempts = 12;
|
||||
const retryMaxAge = 24 * 60 * 60 * 1000;
|
||||
const magicLinkExpiresIn = 30 * 60 * 1000;
|
||||
|
||||
const mailExpiresIn: Partial<Record<MailName, number>> = {
|
||||
SignIn: magicLinkExpiresIn,
|
||||
SignUp: magicLinkExpiresIn,
|
||||
SetPassword: magicLinkExpiresIn,
|
||||
ChangePassword: magicLinkExpiresIn,
|
||||
VerifyEmail: magicLinkExpiresIn,
|
||||
ChangeEmail: magicLinkExpiresIn,
|
||||
VerifyChangeEmail: magicLinkExpiresIn,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MailJob {
|
||||
@@ -66,17 +84,65 @@ export class MailJob {
|
||||
return Math.min(30 * 1000, Math.round(elapsed / 2000) * 1000);
|
||||
}
|
||||
|
||||
private getRetryExhaustedReason({
|
||||
startTime,
|
||||
retryCount,
|
||||
expiresAt,
|
||||
name,
|
||||
}: Jobs['notification.sendMail']) {
|
||||
const expiredAt =
|
||||
expiresAt ?? startTime + (mailExpiresIn[name] ?? retryMaxAge);
|
||||
if (Date.now() > expiredAt) {
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
if ((retryCount ?? 0) > retryMaxAttempts) {
|
||||
return 'max attempts reached';
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
private async shouldSkipRecipient(to: string) {
|
||||
const user = await this.models.user.getUserByEmail(to, {
|
||||
withDisabled: true,
|
||||
});
|
||||
|
||||
return user?.disabled === true;
|
||||
}
|
||||
|
||||
private async deleteRecipientMailCache(to: string) {
|
||||
const suffix = `:${to}`;
|
||||
|
||||
await Promise.all(
|
||||
[sendMailKey, retryMailKey].map(async map => {
|
||||
const keys = await this.cache.mapKeys(map);
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter(key => key.endsWith(suffix))
|
||||
.map(key => this.cache.mapDelete(map, key))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async sendMailInternal({
|
||||
startTime,
|
||||
name,
|
||||
to,
|
||||
props,
|
||||
}: Jobs['notification.sendMail']) {
|
||||
let options: Partial<SendOptions> = {};
|
||||
if (await this.shouldSkipRecipient(to)) {
|
||||
this.logger.debug(`Skip mail [${name}] to disabled user [${to}]`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key in props) {
|
||||
let options: Partial<SendOptions> = {};
|
||||
const renderedProps = { ...props };
|
||||
|
||||
for (const key in renderedProps) {
|
||||
// @ts-expect-error allow
|
||||
const val = props[key];
|
||||
const val = renderedProps[key];
|
||||
if (val && typeof val === 'object') {
|
||||
if ('$$workspaceId' in val) {
|
||||
const workspaceProps = await this.fetchWorkspaceProps(
|
||||
@@ -87,6 +153,16 @@ export class MailJob {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
name === 'MemberInvitation' &&
|
||||
containsUrlOrDomain(workspaceProps.name)
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Skip mail [${name}] to [${to}], reason=workspace name contains url or domain`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (workspaceProps.avatar) {
|
||||
options.attachments = [
|
||||
{
|
||||
@@ -99,7 +175,7 @@ export class MailJob {
|
||||
workspaceProps.avatar = 'cid:workspaceAvatar';
|
||||
}
|
||||
// @ts-expect-error replacement
|
||||
props[key] = workspaceProps;
|
||||
renderedProps[key] = workspaceProps;
|
||||
} else if ('$$userId' in val) {
|
||||
const userProps = await this.fetchUserProps(val.$$userId);
|
||||
|
||||
@@ -108,17 +184,30 @@ export class MailJob {
|
||||
}
|
||||
|
||||
// @ts-expect-error replacement
|
||||
props[key] = userProps;
|
||||
renderedProps[key] = userProps;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
name === 'MemberInvitation' &&
|
||||
'workspace' in renderedProps &&
|
||||
containsUrlOrDomain(
|
||||
(renderedProps.workspace as WorkspaceProps | undefined)?.name
|
||||
)
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Skip mail [${name}] to [${to}], reason=workspace name contains url or domain`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.sender.send(name, {
|
||||
to,
|
||||
...(await Renderers[name](
|
||||
// @ts-expect-error the job trigger part has been typechecked
|
||||
props
|
||||
renderedProps
|
||||
)),
|
||||
...options,
|
||||
});
|
||||
@@ -130,7 +219,7 @@ export class MailJob {
|
||||
}
|
||||
return undefined;
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to send mail [${name}] to [${to}]`, e);
|
||||
this.logger.error(`Failed to send mail [${name}] to [${to}]`, e, props);
|
||||
// wait for a while before retrying
|
||||
const retryDelay = this.calculateRetryDelay(startTime);
|
||||
await sleep(retryDelay);
|
||||
@@ -177,17 +266,41 @@ export class MailJob {
|
||||
@OnJob('notification.sendMail')
|
||||
async sendMail(job: Jobs['notification.sendMail']) {
|
||||
const cacheKey = sendMailCacheKey(job.name, job.to);
|
||||
job.retryCount = (job.retryCount ?? 0) + 1;
|
||||
const exhaustedReason = this.getRetryExhaustedReason(job);
|
||||
if (exhaustedReason) {
|
||||
this.logger.warn(
|
||||
`Drop mail [${job.name}] to [${job.to}], reason=${exhaustedReason}`
|
||||
);
|
||||
await Promise.all([
|
||||
this.cache.mapDelete(sendMailKey, cacheKey),
|
||||
this.cache.mapDelete(retryMailKey, cacheKey),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const retried = await this.cache.mapIncrease(sendMailKey, cacheKey, 1);
|
||||
if (retried <= retryFirstTime) {
|
||||
const ret = await this.sendMailInternal(job);
|
||||
if (!ret) await this.cache.mapDelete(sendMailKey, cacheKey);
|
||||
return ret;
|
||||
}
|
||||
await this.cache.mapSet(retryMailKey, cacheKey, JSON.stringify(job));
|
||||
await this.cache.mapSet(retryMailKey, cacheKey, job);
|
||||
await this.cache.mapDelete(sendMailKey, cacheKey);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@OnEvent('user.deleted')
|
||||
async onUserDeleted(user: Events['user.deleted']) {
|
||||
await Promise.all([
|
||||
this.deleteRecipientMailCache(user.email),
|
||||
this.queue.removeWhere(
|
||||
'notification.sendMail',
|
||||
job => job.to === user.email
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async sendRetryMails() {
|
||||
// pick random one from the retry map
|
||||
@@ -195,9 +308,14 @@ export class MailJob {
|
||||
let key = await this.cache.mapRandomKey(retryMailKey);
|
||||
while (key && processed < retryMaxPerTick) {
|
||||
try {
|
||||
const job = await this.cache.mapGet<string>(retryMailKey, key);
|
||||
const job = await this.cache.mapGet<
|
||||
Jobs['notification.sendMail'] | string
|
||||
>(retryMailKey, key);
|
||||
if (job) {
|
||||
const jobData = JSON.parse(job) as Jobs['notification.sendMail'];
|
||||
const jobData =
|
||||
typeof job === 'string'
|
||||
? (JSON.parse(job) as Jobs['notification.sendMail'])
|
||||
: job;
|
||||
await this.queue.add('notification.sendMail', jobData);
|
||||
// wait for a while before retrying
|
||||
const retryDelay = this.calculateRetryDelay(jobData.startTime);
|
||||
|
||||
@@ -140,7 +140,11 @@ export class MailSender {
|
||||
return true;
|
||||
} catch (e) {
|
||||
metrics.mail.counter('failed_total').add(1, { name });
|
||||
this.logger.error(`Failed to send mail [${name}].`, e);
|
||||
this.logger.error(`Failed to send mail [${name}].`, e, {
|
||||
subject: options.subject,
|
||||
from: options.from,
|
||||
to: options.to,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { isIP } from 'node:net';
|
||||
import { hostname as getHostname } from 'node:os';
|
||||
|
||||
const hostnameLabelRegexp = /^[A-Za-z0-9-]+$/;
|
||||
|
||||
function isValidSMTPAddressLiteral(hostname: string) {
|
||||
if (!hostname.startsWith('[') || !hostname.endsWith(']')) return false;
|
||||
|
||||
const literal = hostname.slice(1, -1);
|
||||
if (!literal || literal.includes(' ')) return false;
|
||||
if (isIP(literal) === 4) return true;
|
||||
|
||||
if (literal.startsWith('IPv6:')) {
|
||||
return isIP(literal.slice('IPv6:'.length)) === 6;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function normalizeSMTPHeloHostname(hostname?: string) {
|
||||
if (!hostname) return undefined;
|
||||
|
||||
const normalized = hostname.trim().replace(/\.$/, '');
|
||||
if (!normalized) return undefined;
|
||||
if (isValidSMTPAddressLiteral(normalized)) return normalized;
|
||||
if (normalized.length > 253) return undefined;
|
||||
|
||||
const labels = normalized.split('.');
|
||||
for (const label of labels) {
|
||||
if (!label || label.length > 63) return undefined;
|
||||
if (
|
||||
!hostnameLabelRegexp.test(label) ||
|
||||
label.startsWith('-') ||
|
||||
label.endsWith('-')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function readSystemHostname() {
|
||||
try {
|
||||
return getHostname();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSMTPHeloHostname(configuredName: string) {
|
||||
const normalizedConfiguredName = normalizeSMTPHeloHostname(configuredName);
|
||||
if (normalizedConfiguredName) return normalizedConfiguredName;
|
||||
return normalizeSMTPHeloHostname(readSystemHostname());
|
||||
}
|
||||
@@ -87,6 +87,29 @@ test('should create invitation notification and email', async t => {
|
||||
t.is(invitationMail.payload.name, 'MemberInvitation');
|
||||
});
|
||||
|
||||
test('should not send invitation email when workspace name contains domain', async t => {
|
||||
const spamWorkspace = await module.create(Mockers.Workspace, {
|
||||
owner: {
|
||||
id: owner.id,
|
||||
},
|
||||
name: 'BTC https://spam.example',
|
||||
});
|
||||
const inviteId = randomUUID();
|
||||
const invitationMailCount = module.queue.count('notification.sendMail');
|
||||
|
||||
const notification = await notificationService.createInvitation({
|
||||
userId: member.id,
|
||||
body: {
|
||||
workspaceId: spamWorkspace.id,
|
||||
createdByUserId: owner.id,
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(notification);
|
||||
t.is(module.queue.count('notification.sendMail'), invitationMailCount);
|
||||
});
|
||||
|
||||
test('should not send invitation email if user setting is not to receive invitation email', async t => {
|
||||
const inviteId = randomUUID();
|
||||
await module.create(Mockers.UserSettings, {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
generateWorkspaceSettingsPath,
|
||||
WorkspaceSettingsTab,
|
||||
} from '../utils/workspace';
|
||||
import { containsUrlOrDomain } from '../workspaces/abuse';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
@@ -151,6 +152,16 @@ export class NotificationService {
|
||||
}
|
||||
|
||||
private async sendInvitationEmail(input: InvitationNotificationCreate) {
|
||||
const workspace = await this.docReader.getWorkspaceContent(
|
||||
input.body.workspaceId
|
||||
);
|
||||
if (containsUrlOrDomain(workspace?.name)) {
|
||||
this.logger.warn(
|
||||
`Skip invitation email for workspace ${input.body.workspaceId}, reason=workspace name contains url or domain`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const inviteUrl = this.url.link(`/invite/${input.body.inviteId}`);
|
||||
if (env.dev) {
|
||||
// make it easier to test in dev mode
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export const SHARE_ACTION_ACCOUNT_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const URL_OR_DOMAIN_PATTERN =
|
||||
/(?:https?:\/\/|www\.|(?<![@\w-])(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}(?:[/?#:]|$))/i;
|
||||
|
||||
export function containsUrlOrDomain(value: string | null | undefined) {
|
||||
return URL_OR_DOMAIN_PATTERN.test(value ?? '');
|
||||
}
|
||||
|
||||
export function isUserOldEnoughForShareActions(user: { createdAt: Date }) {
|
||||
return Date.now() - user.createdAt.getTime() >= SHARE_ACTION_ACCOUNT_AGE_MS;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { PrismaClient } from '@prisma/client';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
|
||||
import {
|
||||
ActionForbidden,
|
||||
Cache,
|
||||
DocActionDenied,
|
||||
DocDefaultRoleCanNotBeOwner,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
DocRole,
|
||||
} from '../../permission';
|
||||
import { PublicUserType, WorkspaceUserType } from '../../user';
|
||||
import { isUserOldEnoughForShareActions } from '../abuse';
|
||||
import { WorkspaceType } from '../types';
|
||||
import { TimeBucket, TimeWindow } from './analytics-types';
|
||||
import {
|
||||
@@ -299,6 +301,15 @@ export class WorkspaceDocResolver {
|
||||
private readonly cache: Cache
|
||||
) {}
|
||||
|
||||
private async assertCanShare(userId: string) {
|
||||
const user = await this.models.user.get(userId);
|
||||
if (!user || !isUserOldEnoughForShareActions(user)) {
|
||||
throw new ActionForbidden(
|
||||
'Sharing links is unavailable during the first 24 hours after signup.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ResolveField(() => WorkspaceDocMeta, {
|
||||
description: 'Cloud page metadata of workspace',
|
||||
complexity: 2,
|
||||
@@ -413,6 +424,7 @@ export class WorkspaceDocResolver {
|
||||
}
|
||||
|
||||
await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish');
|
||||
await this.assertCanShare(user.id);
|
||||
|
||||
const doc = await this.models.doc.publish(workspaceId, docId, mode);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import {
|
||||
ActionForbidden,
|
||||
ActionForbiddenOnNonTeamWorkspace,
|
||||
AlreadyInSpace,
|
||||
AuthenticationRequired,
|
||||
@@ -40,6 +41,7 @@ import { AccessController, WorkspaceRole } from '../../permission';
|
||||
import { QuotaService } from '../../quota';
|
||||
import { UserType } from '../../user';
|
||||
import { validators } from '../../utils/validators';
|
||||
import { containsUrlOrDomain, isUserOldEnoughForShareActions } from '../abuse';
|
||||
import { WorkspaceService } from '../service';
|
||||
import {
|
||||
InvitationType,
|
||||
@@ -68,6 +70,24 @@ export class WorkspaceMemberResolver {
|
||||
private readonly quota: QuotaService
|
||||
) {}
|
||||
|
||||
private async assertCanInviteOrShare(userId: string) {
|
||||
const user = await this.models.user.get(userId);
|
||||
if (!user || !isUserOldEnoughForShareActions(user)) {
|
||||
throw new ActionForbidden(
|
||||
'Inviting members and creating share links are unavailable during the first 24 hours after signup.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertWorkspaceNameCanInvite(workspaceId: string) {
|
||||
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
|
||||
if (containsUrlOrDomain(workspace.name)) {
|
||||
throw new ActionForbidden(
|
||||
'Workspace names containing links or domains cannot be used to invite members.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ResolveField(() => UserType, {
|
||||
description: 'Owner of workspace',
|
||||
complexity: 2,
|
||||
@@ -141,6 +161,8 @@ export class WorkspaceMemberResolver {
|
||||
.user(me.id)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Users.Manage');
|
||||
await this.assertCanInviteOrShare(me.id);
|
||||
await this.assertWorkspaceNameCanInvite(workspaceId);
|
||||
|
||||
if (emails.length > 512) {
|
||||
throw new TooManyRequest();
|
||||
@@ -272,6 +294,8 @@ export class WorkspaceMemberResolver {
|
||||
.user(user.id)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Users.Manage');
|
||||
await this.assertCanInviteOrShare(user.id);
|
||||
await this.assertWorkspaceNameCanInvite(workspaceId);
|
||||
|
||||
const cacheWorkspaceId = `workspace:inviteLink:${workspaceId}`;
|
||||
const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId);
|
||||
|
||||
@@ -83,95 +83,70 @@ export const Renderers = {
|
||||
//#region Workspace
|
||||
MemberInvitation: make(
|
||||
Invitation,
|
||||
props => `${props.user.email} invited you to join ${props.workspace.name}`
|
||||
'You were invited to join a workspace on AFFiNE'
|
||||
),
|
||||
MemberAccepted: make(
|
||||
InvitationAccepted,
|
||||
props => `${props.user.email} accepted your invitation`
|
||||
),
|
||||
MemberLeave: make(
|
||||
MemberLeave,
|
||||
props => `${props.user.email} left ${props.workspace.name}`
|
||||
'Your workspace invitation was accepted'
|
||||
),
|
||||
MemberLeave: make(MemberLeave, 'A workspace member left'),
|
||||
LinkInvitationReviewRequest: make(
|
||||
LinkInvitationReviewRequest,
|
||||
props => `New request to join ${props.workspace.name}`
|
||||
'New request to join a workspace'
|
||||
),
|
||||
LinkInvitationApprove: make(
|
||||
LinkInvitationApproved,
|
||||
props => `Your request to join ${props.workspace.name} has been approved`
|
||||
'Your request to join a workspace has been approved'
|
||||
),
|
||||
LinkInvitationDecline: make(
|
||||
LinkInvitationReviewDeclined,
|
||||
props => `Your request to join ${props.workspace.name} was declined`
|
||||
),
|
||||
MemberRemoved: make(
|
||||
MemberRemoved,
|
||||
props => `You have been removed from ${props.workspace.name}`
|
||||
'Your request to join a workspace was declined'
|
||||
),
|
||||
MemberRemoved: make(MemberRemoved, 'You have been removed from a workspace'),
|
||||
OwnershipTransferred: make(
|
||||
OwnershipTransferred,
|
||||
props => `Your ownership of ${props.workspace.name} has been transferred`
|
||||
'Your workspace ownership has been transferred'
|
||||
),
|
||||
OwnershipReceived: make(
|
||||
OwnershipReceived,
|
||||
props => `You are now the owner of ${props.workspace.name}`
|
||||
'You are now the owner of a workspace'
|
||||
),
|
||||
//#endregion
|
||||
|
||||
//#region Doc
|
||||
Mention: make(
|
||||
Mention,
|
||||
props => `${props.user.email} mentioned you in ${props.doc.title}`
|
||||
),
|
||||
Comment: make(
|
||||
Comment,
|
||||
props => `${props.user.email} commented on ${props.doc.title}`
|
||||
),
|
||||
CommentMention: make(
|
||||
CommentMention,
|
||||
props =>
|
||||
`${props.user.email} mentioned you in a comment on ${props.doc.title}`
|
||||
),
|
||||
Mention: make(Mention, 'You were mentioned in AFFiNE'),
|
||||
Comment: make(Comment, 'New comment in AFFiNE'),
|
||||
CommentMention: make(CommentMention, 'You were mentioned in a comment'),
|
||||
//#endregion
|
||||
|
||||
//#region Team
|
||||
TeamWorkspaceUpgraded: make(TeamWorkspaceUpgraded, props =>
|
||||
props.isOwner
|
||||
? 'Your workspace has been upgraded to team workspace! 🎉'
|
||||
: `${props.workspace.name} has been upgraded to team workspace! 🎉`
|
||||
),
|
||||
TeamBecomeAdmin: make(
|
||||
TeamBecomeAdmin,
|
||||
props => `You are now an admin of ${props.workspace.name}`
|
||||
: 'A workspace has been upgraded to team workspace! 🎉'
|
||||
),
|
||||
TeamBecomeAdmin: make(TeamBecomeAdmin, 'You are now a workspace admin'),
|
||||
TeamBecomeCollaborator: make(
|
||||
TeamBecomeCollaborator,
|
||||
props => `Your role has been changed in ${props.workspace.name}`
|
||||
'Your workspace role has been changed'
|
||||
),
|
||||
TeamDeleteIn24Hours: make(
|
||||
TeamDeleteIn24Hours,
|
||||
props =>
|
||||
`[Action Required] Final warning: Your workspace ${props.workspace.name} will be deleted in 24 hours`
|
||||
'[Action Required] Final warning: Your workspace will be deleted in 24 hours'
|
||||
),
|
||||
TeamDeleteInOneMonth: make(
|
||||
TeamDeleteInOneMonth,
|
||||
props =>
|
||||
`[Action Required] Important: Your workspace ${props.workspace.name} will be deleted soon`
|
||||
'[Action Required] Important: Your workspace will be deleted soon'
|
||||
),
|
||||
TeamWorkspaceDeleted: make(
|
||||
TeamWorkspaceDeleted,
|
||||
props => `Your workspace ${props.workspace.name} has been deleted`
|
||||
'Your workspace has been deleted'
|
||||
),
|
||||
TeamWorkspaceExpireSoon: make(
|
||||
TeamExpireSoon,
|
||||
props =>
|
||||
`[Action Required] Your ${props.workspace.name} team workspace will expire soon`
|
||||
),
|
||||
TeamWorkspaceExpired: make(
|
||||
TeamExpired,
|
||||
props => `Your ${props.workspace.name} team workspace has expired`
|
||||
'[Action Required] Your team workspace will expire soon'
|
||||
),
|
||||
TeamWorkspaceExpired: make(TeamExpired, 'Your team workspace has expired'),
|
||||
//#endregion
|
||||
|
||||
//#region License
|
||||
|
||||
@@ -92,7 +92,7 @@ export class CalendarAccountModel extends BaseModel {
|
||||
scope: input.scope ?? null,
|
||||
status: input.status ?? 'active',
|
||||
lastError: input.lastError ?? null,
|
||||
refreshIntervalMinutes: input.refreshIntervalMinutes ?? 60,
|
||||
refreshIntervalMinutes: input.refreshIntervalMinutes ?? 30,
|
||||
};
|
||||
|
||||
const updateData: Prisma.CalendarAccountUncheckedUpdateInput = {
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface UpsertCalendarSubscriptionInput {
|
||||
export interface UpdateCalendarSubscriptionSyncInput {
|
||||
syncToken?: string | null;
|
||||
lastSyncAt?: Date | null;
|
||||
nextSyncAt?: Date;
|
||||
syncRetryCount?: number;
|
||||
}
|
||||
|
||||
export interface UpdateCalendarSubscriptionChannelInput {
|
||||
@@ -81,13 +83,21 @@ export class CalendarSubscriptionModel extends BaseModel {
|
||||
}
|
||||
|
||||
async updateSync(id: string, input: UpdateCalendarSubscriptionSyncInput) {
|
||||
return await this.db.calendarSubscription.update({
|
||||
where: { id },
|
||||
data: {
|
||||
syncToken: input.syncToken ?? null,
|
||||
lastSyncAt: input.lastSyncAt ?? null,
|
||||
},
|
||||
});
|
||||
const data: Prisma.CalendarSubscriptionUncheckedUpdateInput = {};
|
||||
if (input.syncToken !== undefined) {
|
||||
data.syncToken = input.syncToken ?? null;
|
||||
}
|
||||
if (input.lastSyncAt !== undefined) {
|
||||
data.lastSyncAt = input.lastSyncAt ?? null;
|
||||
}
|
||||
if (input.nextSyncAt !== undefined) {
|
||||
data.nextSyncAt = input.nextSyncAt;
|
||||
}
|
||||
if (input.syncRetryCount !== undefined) {
|
||||
data.syncRetryCount = input.syncRetryCount;
|
||||
}
|
||||
|
||||
return await this.db.calendarSubscription.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async updateChannel(
|
||||
@@ -155,10 +165,16 @@ export class CalendarSubscriptionModel extends BaseModel {
|
||||
});
|
||||
}
|
||||
|
||||
async listAllWithAccountForSync() {
|
||||
async listDueForSync(now: Date, limit: number) {
|
||||
return await this.db.calendarSubscription.findMany({
|
||||
where: { enabled: true },
|
||||
include: { account: true },
|
||||
where: {
|
||||
enabled: true,
|
||||
nextSyncAt: { lte: now },
|
||||
account: { status: 'active' },
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: { nextSyncAt: 'asc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,13 +185,6 @@ export class CalendarSubscriptionModel extends BaseModel {
|
||||
});
|
||||
}
|
||||
|
||||
async updateLastSyncAt(id: string, lastSyncAt: Date) {
|
||||
return await this.db.calendarSubscription.update({
|
||||
where: { id },
|
||||
data: { lastSyncAt },
|
||||
});
|
||||
}
|
||||
|
||||
async clearSyncTokensByAccount(accountId: string) {
|
||||
return await this.db.calendarSubscription.updateMany({
|
||||
where: { accountId },
|
||||
@@ -200,6 +209,7 @@ export class CalendarSubscriptionModel extends BaseModel {
|
||||
data: {
|
||||
enabled: false,
|
||||
syncToken: null,
|
||||
syncRetryCount: 0,
|
||||
customChannelId: null,
|
||||
customResourceId: null,
|
||||
channelExpiration: null,
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from '../../../models';
|
||||
import { Models } from '../../../models';
|
||||
import { CalendarModule } from '../index';
|
||||
import { CalendarCronJobs } from '../cron';
|
||||
import {
|
||||
CalendarProvider,
|
||||
CalendarProviderFactory,
|
||||
@@ -85,6 +86,7 @@ const module = await createModule({
|
||||
],
|
||||
});
|
||||
const calendarService = module.get(CalendarService);
|
||||
const calendarCronJobs = module.get(CalendarCronJobs);
|
||||
const providerFactory = module.get(CalendarProviderFactory);
|
||||
const models = module.get(Models);
|
||||
module.get(CryptoHelper).onConfigInit();
|
||||
@@ -113,6 +115,8 @@ const createSubscription = async (
|
||||
accountId: string,
|
||||
overrides: Partial<UpsertCalendarSubscriptionInput> & {
|
||||
syncToken?: string | null;
|
||||
nextSyncAt?: Date;
|
||||
syncRetryCount?: number;
|
||||
customChannelId?: string | null;
|
||||
customResourceId?: string | null;
|
||||
channelExpiration?: Date | null;
|
||||
@@ -134,6 +138,20 @@ const createSubscription = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
overrides.nextSyncAt !== undefined ||
|
||||
overrides.syncRetryCount !== undefined
|
||||
) {
|
||||
await models.calendarSubscription.updateSync(subscription.id, {
|
||||
...(overrides.nextSyncAt !== undefined
|
||||
? { nextSyncAt: overrides.nextSyncAt }
|
||||
: {}),
|
||||
...(overrides.syncRetryCount !== undefined
|
||||
? { syncRetryCount: overrides.syncRetryCount }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
overrides.customChannelId !== undefined ||
|
||||
overrides.customResourceId !== undefined ||
|
||||
@@ -151,6 +169,8 @@ const createSubscription = async (
|
||||
|
||||
test.afterEach.always(() => {
|
||||
mock.reset();
|
||||
module.queue.add.resetHistory();
|
||||
module.queue.remove.resetHistory();
|
||||
});
|
||||
|
||||
test.after.always(async () => {
|
||||
@@ -252,6 +272,9 @@ test('syncSubscription resets invalid sync token and maps events', async t => {
|
||||
const updated = await models.calendarSubscription.get(subscription.id);
|
||||
t.is(updated?.syncToken, 'next-token');
|
||||
t.truthy(updated?.lastSyncAt);
|
||||
t.is(updated?.syncRetryCount, 0);
|
||||
t.truthy(updated?.nextSyncAt);
|
||||
t.true(updated!.nextSyncAt.getTime() > updated!.lastSyncAt!.getTime());
|
||||
|
||||
const events = await models.calendarEvent.listBySubscriptionsInRange(
|
||||
[subscription.id],
|
||||
@@ -493,51 +516,22 @@ test('syncSubscription applies exponential backoff for repeated failures', async
|
||||
mock.method(Date, 'now', () => now);
|
||||
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
let updated = await models.calendarSubscription.get(subscription.id);
|
||||
t.is(listEventsMock.mock.callCount(), 1);
|
||||
t.is(updated?.syncRetryCount, 1);
|
||||
t.is(
|
||||
updated?.nextSyncAt.toISOString(),
|
||||
new Date(now + baseDelayMs).toISOString()
|
||||
);
|
||||
|
||||
now += baseDelayMs + 1000;
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
updated = await models.calendarSubscription.get(subscription.id);
|
||||
t.is(listEventsMock.mock.callCount(), 2);
|
||||
|
||||
now += baseDelayMs + 1000;
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
t.is(listEventsMock.mock.callCount(), 2);
|
||||
});
|
||||
|
||||
test('syncSubscription skips token refresh while in backoff window', async t => {
|
||||
let now = new Date('2026-01-01T00:00:00.000Z').getTime();
|
||||
mock.method(Date, 'now', () => now);
|
||||
|
||||
const user = await module.create(Mockers.User);
|
||||
const account = await createAccount(user.id, {
|
||||
accessToken: 'expired-access-token',
|
||||
expiresAt: new Date(now - 5 * 60 * 1000),
|
||||
});
|
||||
const subscription = await createSubscription(account.id, {
|
||||
syncToken: 'sync-token',
|
||||
});
|
||||
|
||||
const provider = new MockCalendarProvider();
|
||||
const refreshMock = mock.method(provider, 'refreshTokens', async () => ({
|
||||
accessToken: `refreshed-${randomUUID()}`,
|
||||
}));
|
||||
const listEventsMock = mock.method(provider, 'listEvents', async () => {
|
||||
throw new Error('upstream timeout');
|
||||
});
|
||||
mock.method(providerFactory, 'get', () => provider);
|
||||
|
||||
const baseDelayMs = 5 * 60 * 1000;
|
||||
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
t.is(refreshMock.mock.callCount(), 1);
|
||||
t.is(listEventsMock.mock.callCount(), 1);
|
||||
|
||||
now += baseDelayMs + 1000;
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
t.is(refreshMock.mock.callCount(), 2);
|
||||
t.is(listEventsMock.mock.callCount(), 2);
|
||||
t.is(updated?.syncRetryCount, 2);
|
||||
t.is(
|
||||
updated?.nextSyncAt.toISOString(),
|
||||
new Date(now + baseDelayMs * 2).toISOString()
|
||||
);
|
||||
});
|
||||
|
||||
test('syncSubscription renews webhook channel when expiring', async t => {
|
||||
@@ -599,3 +593,73 @@ test('syncSubscription renews webhook channel when expiring', async t => {
|
||||
t.is(updated?.customResourceId, 'new-resource');
|
||||
t.truthy(updated?.channelExpiration);
|
||||
});
|
||||
|
||||
test('syncSubscription keeps schedule moving when webhook renewal fails', async t => {
|
||||
const now = new Date('2026-01-01T00:00:00.000Z').getTime();
|
||||
mock.method(Date, 'now', () => now);
|
||||
|
||||
const user = await module.create(Mockers.User);
|
||||
const account = await createAccount(user.id, {
|
||||
refreshIntervalMinutes: 60,
|
||||
});
|
||||
const subscription = await createSubscription(account.id, {
|
||||
syncToken: 'sync-token',
|
||||
channelExpiration: new Date(Date.now() + 60 * 60 * 1000),
|
||||
});
|
||||
|
||||
const provider = new MockCalendarProvider();
|
||||
mock.method(provider, 'listEvents', async () => ({
|
||||
events: [],
|
||||
nextSyncToken: 'next-sync',
|
||||
}));
|
||||
mock.method(provider, 'watchCalendar', async () => {
|
||||
throw new Error('watch failed');
|
||||
});
|
||||
mock.method(providerFactory, 'get', () => provider);
|
||||
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
|
||||
const updated = await models.calendarSubscription.get(subscription.id);
|
||||
t.truthy(updated?.lastSyncAt);
|
||||
t.is(updated?.syncRetryCount, 0);
|
||||
t.is(
|
||||
updated?.nextSyncAt.toISOString(),
|
||||
new Date(now + 15 * 60 * 1000).toISOString()
|
||||
);
|
||||
});
|
||||
|
||||
test('pollAccounts skips when nothing is due', async t => {
|
||||
mock.method(models.calendarSubscription, 'listDueForSync', async () => []);
|
||||
|
||||
await calendarCronJobs.pollAccounts();
|
||||
|
||||
t.is(module.queue.count('calendar.syncSubscription'), 0);
|
||||
});
|
||||
|
||||
test('pollAccounts enqueues due subscriptions only', async t => {
|
||||
mock.method(models.calendarSubscription, 'listDueForSync', async () => [
|
||||
{ id: 'due-subscription-a' },
|
||||
{ id: 'due-subscription-b' },
|
||||
]);
|
||||
|
||||
await calendarCronJobs.pollAccounts();
|
||||
|
||||
t.is(module.queue.count('calendar.syncSubscription'), 2);
|
||||
t.deepEqual(
|
||||
module.queue.add
|
||||
.getCalls()
|
||||
.map(call => [call.args[0], call.args[1], call.args[2]]),
|
||||
[
|
||||
[
|
||||
'calendar.syncSubscription',
|
||||
{ subscriptionId: 'due-subscription-a', reason: 'polling' },
|
||||
{ jobId: 'due-subscription-a' },
|
||||
],
|
||||
[
|
||||
'calendar.syncSubscription',
|
||||
{ subscriptionId: 'due-subscription-b', reason: 'polling' },
|
||||
{ jobId: 'due-subscription-b' },
|
||||
],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface CalendarGoogleConfig {
|
||||
clientSecret: string;
|
||||
externalWebhookUrl?: string;
|
||||
webhookVerificationToken?: string;
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export type CalendarCalDAVAuthType = 'auto' | 'basic' | 'digest';
|
||||
@@ -49,6 +50,7 @@ const schema: JSONSchema = {
|
||||
clientSecret: { type: 'string' },
|
||||
externalWebhookUrl: { type: 'string' },
|
||||
webhookVerificationToken: { type: 'string' },
|
||||
requestTimeoutMs: { type: 'number' },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -88,6 +90,7 @@ defineModuleConfig('calendar', {
|
||||
clientSecret: '',
|
||||
externalWebhookUrl: '',
|
||||
webhookVerificationToken: '',
|
||||
requestTimeoutMs: 10_000,
|
||||
},
|
||||
schema,
|
||||
shape: z.object({
|
||||
@@ -101,6 +104,7 @@ defineModuleConfig('calendar', {
|
||||
.or(z.string().length(0))
|
||||
.optional(),
|
||||
webhookVerificationToken: z.string().optional(),
|
||||
requestTimeoutMs: z.number().int().positive().optional(),
|
||||
}),
|
||||
link: 'https://developers.google.com/calendar/api/guides/push',
|
||||
},
|
||||
|
||||
@@ -1,61 +1,33 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { JobQueue } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { CalendarService } from './service';
|
||||
|
||||
const CALENDAR_POLL_BATCH_SIZE = 200;
|
||||
|
||||
@Injectable()
|
||||
export class CalendarCronJobs {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly calendar: CalendarService
|
||||
private readonly queue: JobQueue
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async pollAccounts() {
|
||||
const subscriptions =
|
||||
await this.models.calendarSubscription.listAllWithAccountForSync();
|
||||
const subscriptions = await this.models.calendarSubscription.listDueForSync(
|
||||
new Date(),
|
||||
CALENDAR_POLL_BATCH_SIZE
|
||||
);
|
||||
|
||||
const accountDueAt = new Map<
|
||||
string,
|
||||
{ refreshInterval: number; lastSyncAt: Date | null }
|
||||
>();
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const interval = subscription.account.refreshIntervalMinutes ?? 60;
|
||||
const lastSyncAt = subscription.lastSyncAt ?? null;
|
||||
const existing = accountDueAt.get(subscription.accountId);
|
||||
if (!existing) {
|
||||
accountDueAt.set(subscription.accountId, {
|
||||
refreshInterval: interval,
|
||||
lastSyncAt,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const earliest =
|
||||
existing.lastSyncAt && lastSyncAt
|
||||
? existing.lastSyncAt < lastSyncAt
|
||||
? existing.lastSyncAt
|
||||
: lastSyncAt
|
||||
: (existing.lastSyncAt ?? lastSyncAt);
|
||||
accountDueAt.set(subscription.accountId, {
|
||||
refreshInterval: interval,
|
||||
lastSyncAt: earliest,
|
||||
});
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await Promise.allSettled(
|
||||
Array.from(accountDueAt.entries()).map(([accountId, info]) => {
|
||||
if (
|
||||
!info.lastSyncAt ||
|
||||
now - info.lastSyncAt.getTime() >= info.refreshInterval * 60 * 1000
|
||||
) {
|
||||
return this.calendar.syncAccount(accountId);
|
||||
}
|
||||
return Promise.resolve();
|
||||
})
|
||||
subscriptions.map(({ id }) =>
|
||||
this.queue.add(
|
||||
'calendar.syncSubscription',
|
||||
{ subscriptionId: id, reason: 'polling' },
|
||||
{ jobId: id }
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PermissionModule } from '../../core/permission';
|
||||
import { WorkspaceModule } from '../../core/workspaces';
|
||||
import { CalendarController } from './controller';
|
||||
import { CalendarCronJobs } from './cron';
|
||||
import { CalendarJob } from './job';
|
||||
import { CalendarOAuthService } from './oauth';
|
||||
import { CalendarProviderFactory, CalendarProviders } from './providers';
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ import { CalendarService } from './service';
|
||||
...CalendarProviders,
|
||||
CalendarProviderFactory,
|
||||
CalendarService,
|
||||
CalendarJob,
|
||||
CalendarOAuthService,
|
||||
CalendarCronJobs,
|
||||
CalendarServerConfigResolver,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { OnJob } from '../../base';
|
||||
import { CalendarService } from './service';
|
||||
|
||||
declare global {
|
||||
interface Jobs {
|
||||
'calendar.syncSubscription': {
|
||||
subscriptionId: string;
|
||||
reason?: 'polling' | 'webhook' | 'on-demand';
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CalendarJob {
|
||||
constructor(private readonly calendar: CalendarService) {}
|
||||
|
||||
@OnJob('calendar.syncSubscription')
|
||||
async syncSubscription({
|
||||
subscriptionId,
|
||||
reason,
|
||||
}: Jobs['calendar.syncSubscription']) {
|
||||
await this.calendar.syncSubscription(subscriptionId, { reason });
|
||||
}
|
||||
}
|
||||
@@ -152,10 +152,28 @@ export abstract class CalendarProvider {
|
||||
}
|
||||
}
|
||||
|
||||
protected get requestTimeoutMs() {
|
||||
const timeout = (this.config as { requestTimeoutMs?: number } | undefined)
|
||||
?.requestTimeoutMs;
|
||||
return typeof timeout === 'number' && timeout > 0 ? timeout : undefined;
|
||||
}
|
||||
|
||||
protected withTimeout(signal?: AbortSignal | null) {
|
||||
const timeoutMs = this.requestTimeoutMs;
|
||||
if (!timeoutMs) return signal;
|
||||
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
if (!signal) return timeoutSignal;
|
||||
|
||||
return AbortSignal.any([signal, timeoutSignal]);
|
||||
}
|
||||
|
||||
protected async fetchJson<T>(url: string, init?: RequestInit) {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json', ...init?.headers },
|
||||
...init,
|
||||
signal: this.withTimeout(init?.signal),
|
||||
headers: { ...init?.headers, Accept: 'application/json' },
|
||||
});
|
||||
const body = await response.text();
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -329,6 +329,7 @@ export class GoogleCalendarProvider extends CalendarProvider {
|
||||
|
||||
private async fetchWithTokenHandling<T>(url: string, accessToken: string) {
|
||||
const response = await fetch(url, {
|
||||
signal: this.withTimeout(),
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
|
||||
@@ -8,11 +8,11 @@ import { addDays, subDays } from 'date-fns';
|
||||
import {
|
||||
CalendarProviderRequestError,
|
||||
Config,
|
||||
exponentialBackoffDelay,
|
||||
GraphqlBadRequest,
|
||||
Mutex,
|
||||
JobQueue,
|
||||
URLHelper,
|
||||
} from '../../base';
|
||||
import { SessionRedis } from '../../base/redis';
|
||||
import { Models } from '../../models';
|
||||
import type { CalendarCalDAVProviderPreset } from './config';
|
||||
import {
|
||||
@@ -28,10 +28,10 @@ import type { LinkCalDAVAccountInput } from './types';
|
||||
const TOKEN_REFRESH_SKEW_MS = 60 * 1000;
|
||||
const DEFAULT_PAST_DAYS = 90;
|
||||
const DEFAULT_FUTURE_DAYS = 180;
|
||||
const SYNC_FAILURE_BACKOFF_KEY_PREFIX = 'calendar:sync:backoff:';
|
||||
const SYNC_FAILURE_BACKOFF_BASE_MS = 5 * 60 * 1000;
|
||||
const SYNC_FAILURE_BACKOFF_MAX_MS = 6 * 60 * 60 * 1000;
|
||||
const SYNC_FAILURE_BACKOFF_TTL_SECONDS = 24 * 60 * 60;
|
||||
const DEFAULT_REFRESH_INTERVAL_MINUTES = 30;
|
||||
const CHANNEL_RENEW_RETRY_MS = 15 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class CalendarService {
|
||||
@@ -41,8 +41,7 @@ export class CalendarService {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly providerFactory: CalendarProviderFactory<CalendarProvider>,
|
||||
private readonly mutex: Mutex,
|
||||
private readonly redis: SessionRedis,
|
||||
private readonly queue: JobQueue,
|
||||
private readonly config: Config,
|
||||
private readonly url: URLHelper
|
||||
) {}
|
||||
@@ -85,10 +84,24 @@ export class CalendarService {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.models.calendarAccount.updateRefreshInterval(
|
||||
accountId,
|
||||
refreshIntervalMinutes
|
||||
const updatedAccount =
|
||||
await this.models.calendarAccount.updateRefreshInterval(
|
||||
accountId,
|
||||
refreshIntervalMinutes
|
||||
);
|
||||
const subscriptions =
|
||||
await this.models.calendarSubscription.listByAccountForSync(accountId);
|
||||
await Promise.all(
|
||||
subscriptions.map(subscription =>
|
||||
this.models.calendarSubscription.updateSync(subscription.id, {
|
||||
nextSyncAt: this.calculateNextSyncAt(
|
||||
subscription.lastSyncAt ?? this.now(),
|
||||
refreshIntervalMinutes
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
return updatedAccount;
|
||||
}
|
||||
|
||||
async unlinkAccount(userId: string, accountId: string) {
|
||||
@@ -313,25 +326,6 @@ export class CalendarService {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const backoff = await this.getSyncFailureBackoff(subscription.id);
|
||||
if (backoff && now < backoff.nextRetryAt.getTime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await using lock = await this.mutex.acquire(
|
||||
`calendar:subscription:${subscriptionId}`
|
||||
);
|
||||
if (!lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lockedNow = Date.now();
|
||||
const lockedBackoff = await this.getSyncFailureBackoff(subscription.id);
|
||||
if (lockedBackoff && lockedNow < lockedBackoff.nextRetryAt.getTime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = this.providerFactory.get(
|
||||
account.provider as CalendarProviderName
|
||||
);
|
||||
@@ -415,29 +409,28 @@ export class CalendarService {
|
||||
}
|
||||
|
||||
if (synced) {
|
||||
await this.clearSyncFailureBackoff(subscription.id);
|
||||
await this.ensureWebhookChannel(subscription, provider, accessToken);
|
||||
const syncedAt = this.now();
|
||||
let nextSyncAt = this.calculateNextSyncAt(
|
||||
syncedAt,
|
||||
account.refreshIntervalMinutes
|
||||
);
|
||||
|
||||
try {
|
||||
await this.ensureWebhookChannel(subscription, provider, accessToken);
|
||||
} catch (error) {
|
||||
nextSyncAt = this.calculateChannelRetryAt(nextSyncAt);
|
||||
this.logger.warn(
|
||||
`Failed to ensure webhook channel for subscription ${subscription.id}`,
|
||||
this.toError(error)
|
||||
);
|
||||
}
|
||||
|
||||
await this.models.calendarSubscription.updateSync(subscription.id, {
|
||||
lastSyncAt: syncedAt,
|
||||
nextSyncAt,
|
||||
syncRetryCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
await this.models.calendarSubscription.updateLastSyncAt(
|
||||
subscription.id,
|
||||
new Date()
|
||||
);
|
||||
}
|
||||
|
||||
async syncAccount(accountId: string) {
|
||||
const account = await this.models.calendarAccount.get(accountId);
|
||||
if (!account || account.status !== 'active') {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptions =
|
||||
await this.models.calendarSubscription.listByAccountForSync(accountId);
|
||||
await Promise.allSettled(
|
||||
subscriptions.map(subscription =>
|
||||
this.syncSubscription(subscription.id, { reason: 'polling' })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async listWorkspaceEvents(params: {
|
||||
@@ -455,9 +448,18 @@ export class CalendarService {
|
||||
params.to
|
||||
);
|
||||
|
||||
const subscriptions =
|
||||
await this.models.calendarSubscription.listWithAccounts(subscriptionIds);
|
||||
const staleSubscriptions = subscriptions.filter(
|
||||
subscription =>
|
||||
subscription.enabled &&
|
||||
subscription.account.status === 'active' &&
|
||||
subscription.nextSyncAt.getTime() <= this.nowMs()
|
||||
);
|
||||
|
||||
Promise.allSettled(
|
||||
subscriptionIds.map(subscriptionId =>
|
||||
this.syncSubscription(subscriptionId, { reason: 'on-demand' })
|
||||
staleSubscriptions.map(subscription =>
|
||||
this.enqueueSyncSubscription(subscription.id, 'on-demand')
|
||||
)
|
||||
).catch(error => {
|
||||
this.logger.warn('Calendar on-demand sync failed', error as Error);
|
||||
@@ -513,7 +515,7 @@ export class CalendarService {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.syncSubscription(subscription.id, { reason: 'webhook' });
|
||||
await this.enqueueSyncSubscription(subscription.id, 'webhook');
|
||||
}
|
||||
|
||||
getWebhookToken() {
|
||||
@@ -747,7 +749,7 @@ export class CalendarService {
|
||||
}
|
||||
|
||||
private getSyncWindow() {
|
||||
const now = new Date();
|
||||
const now = this.now();
|
||||
return {
|
||||
timeMin: subDays(now, DEFAULT_PAST_DAYS).toISOString(),
|
||||
timeMax: addDays(now, DEFAULT_FUTURE_DAYS).toISOString(),
|
||||
@@ -767,7 +769,7 @@ export class CalendarService {
|
||||
if (
|
||||
accessToken &&
|
||||
account.expiresAt &&
|
||||
account.expiresAt.getTime() > Date.now() + TOKEN_REFRESH_SKEW_MS
|
||||
account.expiresAt.getTime() > this.nowMs() + TOKEN_REFRESH_SKEW_MS
|
||||
) {
|
||||
return { accessToken };
|
||||
}
|
||||
@@ -831,7 +833,7 @@ export class CalendarService {
|
||||
return;
|
||||
}
|
||||
|
||||
const renewThreshold = Date.now() + 24 * 60 * 60 * 1000;
|
||||
const renewThreshold = this.nowMs() + 24 * 60 * 60 * 1000;
|
||||
if (
|
||||
subscription.channelExpiration &&
|
||||
subscription.channelExpiration.getTime() > renewThreshold
|
||||
@@ -873,6 +875,7 @@ export class CalendarService {
|
||||
subscription: {
|
||||
id: string;
|
||||
externalCalendarId: string;
|
||||
syncRetryCount: number;
|
||||
customChannelId: string | null;
|
||||
customResourceId: string | null;
|
||||
};
|
||||
@@ -895,7 +898,6 @@ export class CalendarService {
|
||||
}
|
||||
|
||||
if (this.isTokenInvalidError(params.error)) {
|
||||
await this.clearSyncFailureBackoff(params.subscription.id);
|
||||
await this.models.calendarAccount.invalidateAndPurge(
|
||||
params.account.id,
|
||||
this.formatSyncError(params.error)
|
||||
@@ -903,18 +905,14 @@ export class CalendarService {
|
||||
return;
|
||||
}
|
||||
|
||||
const backoff = await this.bumpSyncFailureBackoff(params.subscription.id);
|
||||
const interval = params.account.refreshIntervalMinutes ?? 60;
|
||||
const lastSyncAt = this.calculateLastSyncAtForRetry(
|
||||
backoff.nextRetryAt,
|
||||
interval
|
||||
);
|
||||
await this.models.calendarSubscription.updateLastSyncAt(
|
||||
params.subscription.id,
|
||||
lastSyncAt
|
||||
);
|
||||
const attempt = params.subscription.syncRetryCount + 1;
|
||||
const nextRetryAt = this.calculateFailureRetryAt(attempt);
|
||||
await this.models.calendarSubscription.updateSync(params.subscription.id, {
|
||||
nextSyncAt: nextRetryAt,
|
||||
syncRetryCount: attempt,
|
||||
});
|
||||
this.logger.warn(
|
||||
`Calendar sync failed for subscription ${params.subscription.id}, attempt ${backoff.attempt}, next retry at ${backoff.nextRetryAt.toISOString()}`,
|
||||
`Calendar sync failed for subscription ${params.subscription.id}, attempt ${attempt}, next retry at ${nextRetryAt.toISOString()}`,
|
||||
this.toError(params.error)
|
||||
);
|
||||
}
|
||||
@@ -927,15 +925,6 @@ export class CalendarService {
|
||||
return status === 404;
|
||||
}
|
||||
|
||||
private calculateLastSyncAtForRetry(
|
||||
nextRetryAt: Date,
|
||||
refreshIntervalMinutes: number
|
||||
) {
|
||||
// Cron schedules by `now - lastSyncAt >= refreshInterval`, so back-calculate
|
||||
// a synthetic lastSyncAt to defer the next attempt to `nextRetryAt`.
|
||||
return new Date(nextRetryAt.getTime() - refreshIntervalMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
private async disableSubscription(params: {
|
||||
subscriptionId: string;
|
||||
provider: CalendarProvider;
|
||||
@@ -966,68 +955,52 @@ export class CalendarService {
|
||||
await this.models.calendarSubscription.disableAndPurge(
|
||||
params.subscriptionId
|
||||
);
|
||||
await this.clearSyncFailureBackoff(params.subscriptionId);
|
||||
}
|
||||
|
||||
private getSyncFailureBackoffKey(subscriptionId: string) {
|
||||
return `${SYNC_FAILURE_BACKOFF_KEY_PREFIX}${subscriptionId}`;
|
||||
}
|
||||
|
||||
private async getSyncFailureBackoff(subscriptionId: string) {
|
||||
const key = this.getSyncFailureBackoffKey(subscriptionId);
|
||||
const value = await this.redis.get(key);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as {
|
||||
attempt?: number;
|
||||
nextRetryAt?: string;
|
||||
};
|
||||
if (!parsed.attempt || !parsed.nextRetryAt) {
|
||||
return null;
|
||||
async enqueueSyncSubscription(
|
||||
subscriptionId: string,
|
||||
reason: 'polling' | 'webhook' | 'on-demand'
|
||||
) {
|
||||
await this.queue.add(
|
||||
'calendar.syncSubscription',
|
||||
{
|
||||
subscriptionId,
|
||||
reason,
|
||||
},
|
||||
{
|
||||
jobId: subscriptionId,
|
||||
}
|
||||
const nextRetryAt = new Date(parsed.nextRetryAt);
|
||||
if (Number.isNaN(nextRetryAt.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
attempt: parsed.attempt,
|
||||
nextRetryAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async bumpSyncFailureBackoff(subscriptionId: string) {
|
||||
const state = await this.getSyncFailureBackoff(subscriptionId);
|
||||
const attempt = (state?.attempt ?? 0) + 1;
|
||||
const delay = Math.min(
|
||||
SYNC_FAILURE_BACKOFF_BASE_MS * 2 ** (attempt - 1),
|
||||
SYNC_FAILURE_BACKOFF_MAX_MS
|
||||
);
|
||||
const nextRetryAt = new Date(Date.now() + delay);
|
||||
const key = this.getSyncFailureBackoffKey(subscriptionId);
|
||||
await this.redis.set(
|
||||
key,
|
||||
JSON.stringify({
|
||||
attempt,
|
||||
nextRetryAt: nextRetryAt.toISOString(),
|
||||
}),
|
||||
'EX',
|
||||
SYNC_FAILURE_BACKOFF_TTL_SECONDS
|
||||
);
|
||||
return {
|
||||
attempt,
|
||||
nextRetryAt,
|
||||
};
|
||||
private calculateNextSyncAt(base: Date, refreshIntervalMinutes?: number) {
|
||||
const intervalMinutes =
|
||||
refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES;
|
||||
return new Date(base.getTime() + intervalMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
private async clearSyncFailureBackoff(subscriptionId: string) {
|
||||
const key = this.getSyncFailureBackoffKey(subscriptionId);
|
||||
await this.redis.del(key);
|
||||
private calculateChannelRetryAt(nextSyncAt: Date) {
|
||||
return new Date(
|
||||
Math.min(nextSyncAt.getTime(), this.nowMs() + CHANNEL_RENEW_RETRY_MS)
|
||||
);
|
||||
}
|
||||
|
||||
private calculateFailureRetryAt(attempt: number) {
|
||||
return new Date(
|
||||
this.nowMs() +
|
||||
exponentialBackoffDelay(attempt - 1, {
|
||||
baseDelayMs: SYNC_FAILURE_BACKOFF_BASE_MS,
|
||||
maxDelayMs: SYNC_FAILURE_BACKOFF_MAX_MS,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private now() {
|
||||
return new Date(this.nowMs());
|
||||
}
|
||||
|
||||
private nowMs() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
private formatSyncError(error: unknown) {
|
||||
|
||||
@@ -33,19 +33,37 @@ test('should not index workspace if indexer is disabled', async t => {
|
||||
const count = module.queue.count('indexer.indexWorkspace');
|
||||
|
||||
// @ts-expect-error ignore missing fields
|
||||
await indexerEvent.indexWorkspace({ id: 'test-workspace' });
|
||||
await indexerEvent.indexWorkspace({
|
||||
workspaceId: 'test-workspace',
|
||||
docId: 'test-workspace',
|
||||
});
|
||||
|
||||
t.is(module.queue.count('indexer.indexWorkspace'), count);
|
||||
});
|
||||
|
||||
test('should index workspace if indexer is enabled', async t => {
|
||||
test('should index workspace when root snapshot is updated', async t => {
|
||||
// @ts-expect-error ignore missing fields
|
||||
await indexerEvent.indexWorkspace({ id: 'test-workspace' });
|
||||
await indexerEvent.indexWorkspace({
|
||||
workspaceId: 'test-workspace',
|
||||
docId: 'test-workspace',
|
||||
});
|
||||
|
||||
const { payload } = await module.queue.waitFor('indexer.indexWorkspace');
|
||||
t.is(payload.workspaceId, 'test-workspace');
|
||||
});
|
||||
|
||||
test('should not index workspace when non-root snapshot is updated', async t => {
|
||||
const count = module.queue.count('indexer.indexWorkspace');
|
||||
|
||||
// @ts-expect-error ignore missing fields
|
||||
await indexerEvent.indexWorkspace({
|
||||
workspaceId: 'test-workspace',
|
||||
docId: 'child-doc',
|
||||
});
|
||||
|
||||
t.is(module.queue.count('indexer.indexWorkspace'), count);
|
||||
});
|
||||
|
||||
test('should not delete workspace if indexer is disabled', async t => {
|
||||
Sinon.stub(config.indexer, 'enabled').value(false);
|
||||
const count = module.queue.count('indexer.deleteWorkspace');
|
||||
|
||||
@@ -29,21 +29,20 @@ export class IndexerEvent {
|
||||
);
|
||||
}
|
||||
|
||||
@OnEvent('workspace.updated')
|
||||
async indexWorkspace({ id }: Events['workspace.updated']) {
|
||||
@OnEvent('doc.snapshot.updated')
|
||||
async indexWorkspace({ workspaceId, docId }: Events['doc.snapshot.updated']) {
|
||||
if (!this.config.indexer.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (workspaceId !== docId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.queue.add(
|
||||
'indexer.indexWorkspace',
|
||||
{
|
||||
workspaceId: id,
|
||||
},
|
||||
{
|
||||
jobId: `indexWorkspace/${id}`,
|
||||
priority: 100,
|
||||
}
|
||||
{ workspaceId },
|
||||
{ jobId: `indexWorkspace/${workspaceId}`, priority: 100 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -114,22 +114,17 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
throw new ManagedByAppStoreOrPlay();
|
||||
}
|
||||
|
||||
const subscription = await this.getSubscription({
|
||||
plan: lookupKey.plan,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (
|
||||
subscription &&
|
||||
active &&
|
||||
// do not allow to re-subscribe unless
|
||||
!(
|
||||
/* current subscription is a onetime subscription and so as the one that's checking out */
|
||||
(
|
||||
(subscription.variant === SubscriptionVariant.Onetime &&
|
||||
(active.variant === SubscriptionVariant.Onetime &&
|
||||
lookupKey.variant === SubscriptionVariant.Onetime) ||
|
||||
/* current subscription is normal subscription and is checking-out a lifetime subscription */
|
||||
(subscription.recurring !== SubscriptionRecurring.Lifetime &&
|
||||
subscription.variant !== SubscriptionVariant.Onetime &&
|
||||
(active.recurring !== SubscriptionRecurring.Lifetime &&
|
||||
active.variant !== SubscriptionVariant.Onetime &&
|
||||
lookupKey.recurring === SubscriptionRecurring.Lifetime)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
mutation sendTestEmail($host: String!, $port: Int!, $sender: String!, $username: String!, $password: String!, $ignoreTLS: Boolean!) {
|
||||
sendTestEmail(config: {
|
||||
host: $host,
|
||||
port: $port,
|
||||
sender: $sender,
|
||||
username: $username,
|
||||
password: $password,
|
||||
ignoreTLS: $ignoreTLS,
|
||||
})
|
||||
}
|
||||
mutation sendTestEmail(
|
||||
$name: String!
|
||||
$host: String!
|
||||
$port: Int!
|
||||
$sender: String!
|
||||
$username: String!
|
||||
$password: String!
|
||||
$ignoreTLS: Boolean!
|
||||
) {
|
||||
sendTestEmail(
|
||||
config: {
|
||||
name: $name
|
||||
host: $host
|
||||
port: $port
|
||||
sender: $sender
|
||||
username: $username
|
||||
password: $password
|
||||
ignoreTLS: $ignoreTLS
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -549,9 +549,9 @@ export const listUsersQuery = {
|
||||
export const sendTestEmailMutation = {
|
||||
id: 'sendTestEmailMutation' as const,
|
||||
op: 'sendTestEmail',
|
||||
query: `mutation sendTestEmail($host: String!, $port: Int!, $sender: String!, $username: String!, $password: String!, $ignoreTLS: Boolean!) {
|
||||
query: `mutation sendTestEmail($name: String!, $host: String!, $port: Int!, $sender: String!, $username: String!, $password: String!, $ignoreTLS: Boolean!) {
|
||||
sendTestEmail(
|
||||
config: {host: $host, port: $port, sender: $sender, username: $username, password: $password, ignoreTLS: $ignoreTLS}
|
||||
config: {name: $name, host: $host, port: $port, sender: $sender, username: $username, password: $password, ignoreTLS: $ignoreTLS}
|
||||
)
|
||||
}`,
|
||||
};
|
||||
|
||||
@@ -4027,6 +4027,7 @@ export type ListUsersQuery = {
|
||||
};
|
||||
|
||||
export type SendTestEmailMutationVariables = Exact<{
|
||||
name: Scalars['String']['input'];
|
||||
host: Scalars['String']['input'];
|
||||
port: Scalars['Int']['input'];
|
||||
sender: Scalars['String']['input'];
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
"type": "Object",
|
||||
"desc": "The config for copilot job queue"
|
||||
},
|
||||
"queues.calendar": {
|
||||
"type": "Object",
|
||||
"desc": "The config for calendar job queue"
|
||||
},
|
||||
"queues.doc": {
|
||||
"type": "Object",
|
||||
"desc": "The config for doc job queue"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"pl": 98,
|
||||
"pt-BR": 96,
|
||||
"ru": 98,
|
||||
"sv-SE": 97,
|
||||
"sv-SE": 96,
|
||||
"uk": 96,
|
||||
"ur": 2,
|
||||
"zh-Hans": 98,
|
||||
|
||||
+52
-4
@@ -73,8 +73,8 @@ update_app_stream_version() {
|
||||
|
||||
update_ios_marketing_version() {
|
||||
local file_path=$1
|
||||
# Remove everything after the "-"
|
||||
local new_version=$(echo "$2" | sed -E 's/-.*$//')
|
||||
# Normalize inputs like "v0.26.4-beta.1" to "0.26.4"
|
||||
local new_version=$(echo "$2" | sed -E 's/^v//; s/-.*$//')
|
||||
|
||||
# Check if file exists
|
||||
if [ ! -f "$file_path" ]; then
|
||||
@@ -98,8 +98,56 @@ update_ios_marketing_version() {
|
||||
rm "$file_path".bak
|
||||
}
|
||||
|
||||
# Derive a date-based iOS MARKETING_VERSION from the latest stable/beta tag.
|
||||
# Apple requires CFBundleShortVersionString to increase monotonically. Using
|
||||
# date-based versions (YYYY.M.D) derived from the last stable/beta release tag
|
||||
# ensures this. The user-facing App Store version is set separately in
|
||||
# App Store Connect.
|
||||
get_ios_version_from_git() {
|
||||
# Find the most recent stable/beta tag reachable from HEAD (exclude canary/nightly)
|
||||
local latest_tag
|
||||
latest_tag=$(git describe --tags --match 'v[0-9]*' \
|
||||
--exclude '*canary*' --exclude '*nightly*' \
|
||||
--abbrev=0 HEAD 2>/dev/null)
|
||||
|
||||
if [ -z "$latest_tag" ]; then
|
||||
# No stable/beta tag found, fall back to today's date
|
||||
date +"%Y.%-m.%-d"
|
||||
return
|
||||
fi
|
||||
|
||||
# Get the tag creation date (tagger date for annotated tags, commit date for lightweight)
|
||||
local tag_date
|
||||
tag_date=$(git for-each-ref --format='%(creatordate:short)' "refs/tags/$latest_tag")
|
||||
|
||||
if [ -z "$tag_date" ]; then
|
||||
date +"%Y.%-m.%-d"
|
||||
return
|
||||
fi
|
||||
|
||||
# Format as YYYY.M.D (no leading zeros for month/day)
|
||||
local year month day
|
||||
year=$(echo "$tag_date" | cut -d'-' -f1)
|
||||
month=$((10#$(echo "$tag_date" | cut -d'-' -f2)))
|
||||
day=$((10#$(echo "$tag_date" | cut -d'-' -f3)))
|
||||
|
||||
echo "${year}.${month}.${day}"
|
||||
}
|
||||
|
||||
new_version=$1
|
||||
ios_new_version=${IOS_APP_VERSION:-$new_version}
|
||||
|
||||
if [ -n "$IOS_APP_VERSION" ]; then
|
||||
# Manual override via environment variable
|
||||
ios_new_version=$IOS_APP_VERSION
|
||||
elif echo "$new_version" | grep -qE '(canary|nightly)'; then
|
||||
# Canary/nightly: use the date of the last stable/beta tag
|
||||
ios_new_version=$(get_ios_version_from_git)
|
||||
else
|
||||
# Stable/beta release: use today's date
|
||||
ios_new_version=$(date +"%Y.%-m.%-d")
|
||||
fi
|
||||
|
||||
echo "iOS MARKETING_VERSION: $ios_new_version (app version: $new_version)"
|
||||
|
||||
update_app_version_in_helm_charts ".github/helm/affine/Chart.yaml" "$new_version"
|
||||
update_app_version_in_helm_charts ".github/helm/affine/charts/graphql/Chart.yaml" "$new_version"
|
||||
@@ -108,4 +156,4 @@ update_app_version_in_helm_charts ".github/helm/affine/charts/doc/Chart.yaml" "$
|
||||
|
||||
update_app_stream_version "packages/frontend/apps/electron/resources/affine.metainfo.xml" "$new_version"
|
||||
|
||||
update_ios_marketing_version "packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj" "$new_version"
|
||||
update_ios_marketing_version "packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj" "$ios_new_version"
|
||||
|
||||
@@ -1034,8 +1034,7 @@ __metadata:
|
||||
"@types/nodemailer": "npm:^7.0.0"
|
||||
"@types/on-headers": "npm:^1.0.3"
|
||||
"@types/react": "npm:^19.0.1"
|
||||
"@types/react-dom": "npm:^19.0.2"
|
||||
"@types/semver": "npm:^7.5.8"
|
||||
"@types/semver": "npm:^7.7.1"
|
||||
"@types/sinon": "npm:^21.0.0"
|
||||
"@types/supertest": "npm:^6.0.2"
|
||||
ai: "npm:^5.0.118"
|
||||
@@ -1078,7 +1077,7 @@ __metadata:
|
||||
react-email: "npm:^4.3.2"
|
||||
reflect-metadata: "npm:^0.2.2"
|
||||
rxjs: "npm:^7.8.2"
|
||||
semver: "npm:^7.7.3"
|
||||
semver: "npm:^7.7.4"
|
||||
ses: "npm:^1.14.0"
|
||||
sinon: "npm:^21.0.1"
|
||||
socket.io: "npm:^4.8.1"
|
||||
@@ -6220,17 +6219,17 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"@graphql-tools/graphql-file-loader@npm:^8.0.0":
|
||||
version: 8.0.19
|
||||
resolution: "@graphql-tools/graphql-file-loader@npm:8.0.19"
|
||||
version: 8.1.12
|
||||
resolution: "@graphql-tools/graphql-file-loader@npm:8.1.12"
|
||||
dependencies:
|
||||
"@graphql-tools/import": "npm:7.0.18"
|
||||
"@graphql-tools/utils": "npm:^10.8.6"
|
||||
"@graphql-tools/import": "npm:^7.1.12"
|
||||
"@graphql-tools/utils": "npm:^11.0.0"
|
||||
globby: "npm:^11.0.3"
|
||||
tslib: "npm:^2.4.0"
|
||||
unixify: "npm:^1.0.0"
|
||||
peerDependencies:
|
||||
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
|
||||
checksum: 10/f7b8871582fdb925ba4b54463bdad651777a53769eafbf1febe1783ab25208a1c787fa2f3a16caabee0f9c1621adad47df539973539c57037f748c6c9d4ef103
|
||||
checksum: 10/774f6c14e511d91ded6b7c338bd55bb31c3c5256af14799ae117f4580f6b7341fef3ca85e83d559b4400e0e933d04cb22d6c29390bf774e38d3d03b009203795
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -6251,48 +6250,48 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@graphql-tools/import@npm:7.0.18":
|
||||
version: 7.0.18
|
||||
resolution: "@graphql-tools/import@npm:7.0.18"
|
||||
"@graphql-tools/import@npm:^7.1.12":
|
||||
version: 7.1.12
|
||||
resolution: "@graphql-tools/import@npm:7.1.12"
|
||||
dependencies:
|
||||
"@graphql-tools/utils": "npm:^10.8.6"
|
||||
"@graphql-tools/utils": "npm:^11.0.0"
|
||||
resolve-from: "npm:5.0.0"
|
||||
tslib: "npm:^2.4.0"
|
||||
peerDependencies:
|
||||
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
|
||||
checksum: 10/739086fcaffefa8efa38648477fd4aea41a87b5fa3f78552a2a7b6249dce1d84d84e164e61c73811d6ce9e8f5754b6bab675f7255482bf5ba080f83759a37138
|
||||
checksum: 10/9c65b2fb5221e20955ae865454ff89fa9910e0282416e9f60826b3dc512c3b4cc71dba8784eecc574925486390c2e31e0b51d284781710df9f865c15811866f3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@graphql-tools/json-file-loader@npm:^8.0.0":
|
||||
version: 8.0.18
|
||||
resolution: "@graphql-tools/json-file-loader@npm:8.0.18"
|
||||
version: 8.0.26
|
||||
resolution: "@graphql-tools/json-file-loader@npm:8.0.26"
|
||||
dependencies:
|
||||
"@graphql-tools/utils": "npm:^10.8.6"
|
||||
"@graphql-tools/utils": "npm:^11.0.0"
|
||||
globby: "npm:^11.0.3"
|
||||
tslib: "npm:^2.4.0"
|
||||
unixify: "npm:^1.0.0"
|
||||
peerDependencies:
|
||||
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
|
||||
checksum: 10/a2e6e37c0565674c18aa6c4d5574924fefda441c3bbfd40ff123ab747c6f542f99d3669f204c2f616a3cecdd8cb76a9d827bf50af13faf4a299e54f1577a9136
|
||||
checksum: 10/6e4dc44a9aa3cdd3f7b958b50d98a26e56fb5cc9fab2667011ec12c82685016f269350d6ba2e45dcc7e6d71f8df7d1c4002edb55ce8896ba3e705cad14645ac1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@graphql-tools/load@npm:^8.1.0":
|
||||
version: 8.1.0
|
||||
resolution: "@graphql-tools/load@npm:8.1.0"
|
||||
version: 8.1.8
|
||||
resolution: "@graphql-tools/load@npm:8.1.8"
|
||||
dependencies:
|
||||
"@graphql-tools/schema": "npm:^10.0.23"
|
||||
"@graphql-tools/utils": "npm:^10.8.6"
|
||||
"@graphql-tools/schema": "npm:^10.0.31"
|
||||
"@graphql-tools/utils": "npm:^11.0.0"
|
||||
p-limit: "npm:3.1.0"
|
||||
tslib: "npm:^2.4.0"
|
||||
peerDependencies:
|
||||
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
|
||||
checksum: 10/4601dda7eb32cb8afed2379102ad82f8a948e478f42c7b1f354a3468ca8dfcdcc2a89e6c6ebcbb574c77eaa80d47f20c27230bdcd6c2d0a3600fa1d6a450cc95
|
||||
checksum: 10/a9b24c8d9fc52ebf2b5e0d5dc99212e61704cfe0a07b17a5be3329c391ae01f710e0a2ca6b73b41379e9bee55210c0466d5dfb378a9e3cbe051062a69a07d616
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@graphql-tools/merge@npm:9.0.24, @graphql-tools/merge@npm:^9.0.0, @graphql-tools/merge@npm:^9.0.24":
|
||||
"@graphql-tools/merge@npm:9.0.24":
|
||||
version: 9.0.24
|
||||
resolution: "@graphql-tools/merge@npm:9.0.24"
|
||||
dependencies:
|
||||
@@ -6316,6 +6315,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@graphql-tools/merge@npm:^9.0.0, @graphql-tools/merge@npm:^9.0.24, @graphql-tools/merge@npm:^9.1.7":
|
||||
version: 9.1.7
|
||||
resolution: "@graphql-tools/merge@npm:9.1.7"
|
||||
dependencies:
|
||||
"@graphql-tools/utils": "npm:^11.0.0"
|
||||
tslib: "npm:^2.4.0"
|
||||
peerDependencies:
|
||||
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
|
||||
checksum: 10/e0b77dfc16e91d7c2450df0b57a85a93e11f0f67e37e396bcf04275d1db8ed1b7257c763ebe6e7f122041d81f00d6aa954fbec531fa6c0b449d195a9aff199cc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@graphql-tools/optimize@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "@graphql-tools/optimize@npm:2.0.0"
|
||||
@@ -6366,7 +6377,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@graphql-tools/schema@npm:10.0.23, @graphql-tools/schema@npm:^10.0.0, @graphql-tools/schema@npm:^10.0.11, @graphql-tools/schema@npm:^10.0.23":
|
||||
"@graphql-tools/schema@npm:10.0.23":
|
||||
version: 10.0.23
|
||||
resolution: "@graphql-tools/schema@npm:10.0.23"
|
||||
dependencies:
|
||||
@@ -6379,6 +6390,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@graphql-tools/schema@npm:^10.0.0, @graphql-tools/schema@npm:^10.0.11, @graphql-tools/schema@npm:^10.0.31":
|
||||
version: 10.0.31
|
||||
resolution: "@graphql-tools/schema@npm:10.0.31"
|
||||
dependencies:
|
||||
"@graphql-tools/merge": "npm:^9.1.7"
|
||||
"@graphql-tools/utils": "npm:^11.0.0"
|
||||
tslib: "npm:^2.4.0"
|
||||
peerDependencies:
|
||||
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
|
||||
checksum: 10/5b775736f8b8454319e07cadc7d41bc5c9cc804a393490aaffd1bb7f59afddb02b498837e870bf98db4a1a989a721d5d8e2fd2b97409d078bac14503c2d4f9cb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@graphql-tools/schema@npm:^9.0.0":
|
||||
version: 9.0.19
|
||||
resolution: "@graphql-tools/schema@npm:9.0.19"
|
||||
@@ -6430,6 +6454,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@graphql-tools/utils@npm:^11.0.0":
|
||||
version: 11.0.0
|
||||
resolution: "@graphql-tools/utils@npm:11.0.0"
|
||||
dependencies:
|
||||
"@graphql-typed-document-node/core": "npm:^3.1.1"
|
||||
"@whatwg-node/promise-helpers": "npm:^1.0.0"
|
||||
cross-inspect: "npm:1.0.1"
|
||||
tslib: "npm:^2.4.0"
|
||||
peerDependencies:
|
||||
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
|
||||
checksum: 10/4cc7577ab85d60908a1d5d448071b318791b798f571cd4b8e4289e0e0eeae9d7183b661a1a7d5da3cedaf5f9b62b936031e3a90d2e17a1c50acbd95d9106ba3c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@graphql-tools/utils@npm:^9.2.1":
|
||||
version: 9.2.1
|
||||
resolution: "@graphql-tools/utils@npm:9.2.1"
|
||||
@@ -9898,6 +9936,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/api-logs@npm:0.213.0":
|
||||
version: 0.213.0
|
||||
resolution: "@opentelemetry/api-logs@npm:0.213.0"
|
||||
dependencies:
|
||||
"@opentelemetry/api": "npm:^1.3.0"
|
||||
checksum: 10/9b2d030d8534520f23e3903ccf64dc479fb4d37fcf5ca265ca7de439ec8dd5c849aaa62068278cd97879da7c9528e34c7162cc7bbd025dd8d74667843adc151f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/api@npm:1.9.0, @opentelemetry/api@npm:^1.3.0, @opentelemetry/api@npm:^1.9.0":
|
||||
version: 1.9.0
|
||||
resolution: "@opentelemetry/api@npm:1.9.0"
|
||||
@@ -9917,7 +9964,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/context-async-hooks@npm:2.5.0, @opentelemetry/context-async-hooks@npm:^2.2.0":
|
||||
"@opentelemetry/context-async-hooks@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@opentelemetry/context-async-hooks@npm:2.5.0"
|
||||
peerDependencies:
|
||||
@@ -9926,6 +9973,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/context-async-hooks@npm:2.6.0, @opentelemetry/context-async-hooks@npm:^2.2.0":
|
||||
version: 2.6.0
|
||||
resolution: "@opentelemetry/context-async-hooks@npm:2.6.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
checksum: 10/a1f746fb9bb25b4c40c0da4cc68a7412e82f120a6ddc80dcf0117432418e64c947527bca87895ebce4211a09992863a75a0f5b5f8e7185a573244cccb4809c42
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/core@npm:2.2.0":
|
||||
version: 2.2.0
|
||||
resolution: "@opentelemetry/core@npm:2.2.0"
|
||||
@@ -9937,7 +9993,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/core@npm:2.5.0, @opentelemetry/core@npm:^2.0.0, @opentelemetry/core@npm:^2.2.0":
|
||||
"@opentelemetry/core@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@opentelemetry/core@npm:2.5.0"
|
||||
dependencies:
|
||||
@@ -9948,6 +10004,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/core@npm:2.6.0, @opentelemetry/core@npm:^2.0.0, @opentelemetry/core@npm:^2.2.0":
|
||||
version: 2.6.0
|
||||
resolution: "@opentelemetry/core@npm:2.6.0"
|
||||
dependencies:
|
||||
"@opentelemetry/semantic-conventions": "npm:^1.29.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
checksum: 10/21c017cc68fe7836d06ecac31abeba6ad610dd42e9c1cb9562cd13eed3f644c48111a1fc7d00dfc2b7cc179d40f059482c69c978c24352ac0c605f30686a01a4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/exporter-logs-otlp-grpc@npm:0.211.0":
|
||||
version: 0.211.0
|
||||
resolution: "@opentelemetry/exporter-logs-otlp-grpc@npm:0.211.0"
|
||||
@@ -10477,7 +10544,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/instrumentation@npm:0.211.0, @opentelemetry/instrumentation@npm:>=0.52.0 <1, @opentelemetry/instrumentation@npm:^0.211.0":
|
||||
"@opentelemetry/instrumentation@npm:0.211.0, @opentelemetry/instrumentation@npm:^0.211.0":
|
||||
version: 0.211.0
|
||||
resolution: "@opentelemetry/instrumentation@npm:0.211.0"
|
||||
dependencies:
|
||||
@@ -10490,6 +10557,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/instrumentation@npm:>=0.52.0 <1":
|
||||
version: 0.213.0
|
||||
resolution: "@opentelemetry/instrumentation@npm:0.213.0"
|
||||
dependencies:
|
||||
"@opentelemetry/api-logs": "npm:0.213.0"
|
||||
import-in-the-middle: "npm:^3.0.0"
|
||||
require-in-the-middle: "npm:^8.0.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ^1.3.0
|
||||
checksum: 10/69baeaae0c5836ede140485530954d32c8d20d864340f7d57b43a6e3ef9c10394d29a1884dd2b076512aec896039a1ea02f698282649b5aa3fa59b13bca00f97
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/otlp-exporter-base@npm:0.211.0":
|
||||
version: 0.211.0
|
||||
resolution: "@opentelemetry/otlp-exporter-base@npm:0.211.0"
|
||||
@@ -10562,7 +10642,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/resources@npm:2.5.0, @opentelemetry/resources@npm:^2.2.0":
|
||||
"@opentelemetry/resources@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@opentelemetry/resources@npm:2.5.0"
|
||||
dependencies:
|
||||
@@ -10574,6 +10654,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/resources@npm:2.6.0, @opentelemetry/resources@npm:^2.2.0":
|
||||
version: 2.6.0
|
||||
resolution: "@opentelemetry/resources@npm:2.6.0"
|
||||
dependencies:
|
||||
"@opentelemetry/core": "npm:2.6.0"
|
||||
"@opentelemetry/semantic-conventions": "npm:^1.29.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
checksum: 10/837e76911d013e52c1c0cd8da6f2912818fcc107fd1c6fcb2ec8faa6e617b802e0eef1aa53bd4417c7a77c96ea02b6f5bbdccb9ff411ef8efeff49c2e02d9443
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-logs@npm:0.211.0":
|
||||
version: 0.211.0
|
||||
resolution: "@opentelemetry/sdk-logs@npm:0.211.0"
|
||||
@@ -10587,7 +10679,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-metrics@npm:2.5.0, @opentelemetry/sdk-metrics@npm:^2.2.0":
|
||||
"@opentelemetry/sdk-metrics@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@opentelemetry/sdk-metrics@npm:2.5.0"
|
||||
dependencies:
|
||||
@@ -10599,6 +10691,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-metrics@npm:^2.2.0":
|
||||
version: 2.6.0
|
||||
resolution: "@opentelemetry/sdk-metrics@npm:2.6.0"
|
||||
dependencies:
|
||||
"@opentelemetry/core": "npm:2.6.0"
|
||||
"@opentelemetry/resources": "npm:2.6.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ">=1.9.0 <1.10.0"
|
||||
checksum: 10/5fd1254ab86cdb6573999f3c5d60b8332fb3a2b7d50d1befcfd9d8ef021d5e9e405e31394f7aa4a106a4546bd6308a652a78cd9a35d6fbe56e44984d605cb5d5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-node@npm:^0.211.0":
|
||||
version: 0.211.0
|
||||
resolution: "@opentelemetry/sdk-node@npm:0.211.0"
|
||||
@@ -10633,7 +10737,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-trace-base@npm:2.5.0, @opentelemetry/sdk-trace-base@npm:^2.2.0":
|
||||
"@opentelemetry/sdk-trace-base@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@opentelemetry/sdk-trace-base@npm:2.5.0"
|
||||
dependencies:
|
||||
@@ -10646,7 +10750,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-trace-node@npm:2.5.0, @opentelemetry/sdk-trace-node@npm:^2.2.0":
|
||||
"@opentelemetry/sdk-trace-base@npm:2.6.0, @opentelemetry/sdk-trace-base@npm:^2.2.0":
|
||||
version: 2.6.0
|
||||
resolution: "@opentelemetry/sdk-trace-base@npm:2.6.0"
|
||||
dependencies:
|
||||
"@opentelemetry/core": "npm:2.6.0"
|
||||
"@opentelemetry/resources": "npm:2.6.0"
|
||||
"@opentelemetry/semantic-conventions": "npm:^1.29.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
checksum: 10/8ca3c1c4d7a95ec8a28ab5237162e31334216a59408e9d9d10ad51f5709911a405699ad69f445c212aad55fb6cc2c70f473b835e9e52bf4ed63f237c4d1813af
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-trace-node@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@opentelemetry/sdk-trace-node@npm:2.5.0"
|
||||
dependencies:
|
||||
@@ -10659,6 +10776,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-trace-node@npm:^2.2.0":
|
||||
version: 2.6.0
|
||||
resolution: "@opentelemetry/sdk-trace-node@npm:2.6.0"
|
||||
dependencies:
|
||||
"@opentelemetry/context-async-hooks": "npm:2.6.0"
|
||||
"@opentelemetry/core": "npm:2.6.0"
|
||||
"@opentelemetry/sdk-trace-base": "npm:2.6.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
checksum: 10/b1576f44198ae18ab36dea92e5c254eb6a96ca5793a5adbb96a01c586dd992e3532eb291c9ea225e2cd6979f7f6dd3fca2fad56ab360ba573f8b3bfb462dd2aa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/semantic-conventions@npm:^1.22.0, @opentelemetry/semantic-conventions@npm:^1.24.0, @opentelemetry/semantic-conventions@npm:^1.27.0, @opentelemetry/semantic-conventions@npm:^1.29.0, @opentelemetry/semantic-conventions@npm:^1.30.0, @opentelemetry/semantic-conventions@npm:^1.33.0, @opentelemetry/semantic-conventions@npm:^1.33.1, @opentelemetry/semantic-conventions@npm:^1.34.0, @opentelemetry/semantic-conventions@npm:^1.36.0, @opentelemetry/semantic-conventions@npm:^1.37.0, @opentelemetry/semantic-conventions@npm:^1.38.0":
|
||||
version: 1.39.0
|
||||
resolution: "@opentelemetry/semantic-conventions@npm:1.39.0"
|
||||
@@ -17305,10 +17435,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/semver@npm:^7, @types/semver@npm:^7.5.8":
|
||||
version: 7.7.0
|
||||
resolution: "@types/semver@npm:7.7.0"
|
||||
checksum: 10/ee4514c6c852b1c38f951239db02f9edeea39f5310fad9396a00b51efa2a2d96b3dfca1ae84c88181ea5b7157c57d32d7ef94edacee36fbf975546396b85ba5b
|
||||
"@types/semver@npm:^7, @types/semver@npm:^7.7.1":
|
||||
version: 7.7.1
|
||||
resolution: "@types/semver@npm:7.7.1"
|
||||
checksum: 10/8f09e7e6ca3ded67d78ba7a8f7535c8d9cf8ced83c52e7f3ac3c281fe8c689c3fe475d199d94390dc04fc681d51f2358b430bb7b2e21c62de24f2bee2c719068
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -18402,12 +18532,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"acorn@npm:^8.11.0, acorn@npm:^8.14.0, acorn@npm:^8.15.0, acorn@npm:^8.6.0, acorn@npm:^8.7.1, acorn@npm:^8.8.1, acorn@npm:^8.8.2":
|
||||
version: 8.15.0
|
||||
resolution: "acorn@npm:8.15.0"
|
||||
"acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.6.0, acorn@npm:^8.7.1, acorn@npm:^8.8.1, acorn@npm:^8.8.2":
|
||||
version: 8.16.0
|
||||
resolution: "acorn@npm:8.16.0"
|
||||
bin:
|
||||
acorn: bin/acorn
|
||||
checksum: 10/77f2de5051a631cf1729c090e5759148459cdb76b5f5c70f890503d629cf5052357b0ce783c0f976dd8a93c5150f59f6d18df1def3f502396a20f81282482fa4
|
||||
checksum: 10/690c673bb4d61b38ef82795fab58526471ad7f7e67c0e40c4ff1e10ecd80ce5312554ef633c9995bfc4e6d170cef165711f9ca9e49040b62c0c66fbf2dd3df2b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -18603,10 +18733,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ansi-regex@npm:^6.0.1":
|
||||
version: 6.1.0
|
||||
resolution: "ansi-regex@npm:6.1.0"
|
||||
checksum: 10/495834a53b0856c02acd40446f7130cb0f8284f4a39afdab20d5dc42b2e198b1196119fe887beed8f9055c4ff2055e3b2f6d4641d0be018cdfb64fedf6fc1aac
|
||||
"ansi-regex@npm:^6.2.2":
|
||||
version: 6.2.2
|
||||
resolution: "ansi-regex@npm:6.2.2"
|
||||
checksum: 10/9b17ce2c6daecc75bcd5966b9ad672c23b184dc3ed9bf3c98a0702f0d2f736c15c10d461913568f2cf527a5e64291c7473358885dd493305c84a1cfed66ba94f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -18627,9 +18757,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"ansi-styles@npm:^6.0.0, ansi-styles@npm:^6.1.0, ansi-styles@npm:^6.2.1":
|
||||
version: 6.2.1
|
||||
resolution: "ansi-styles@npm:6.2.1"
|
||||
checksum: 10/70fdf883b704d17a5dfc9cde206e698c16bcd74e7f196ab821511651aee4f9f76c9514bdfa6ca3a27b5e49138b89cb222a28caf3afe4567570139577f991df32
|
||||
version: 6.2.3
|
||||
resolution: "ansi-styles@npm:6.2.3"
|
||||
checksum: 10/c49dad7639f3e48859bd51824c93b9eb0db628afc243c51c3dd2410c4a15ede1a83881c6c7341aa2b159c4f90c11befb38f2ba848c07c66c9f9de4bcd7cb9f30
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -19347,12 +19477,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"brace-expansion@npm:^2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "brace-expansion@npm:2.0.1"
|
||||
"brace-expansion@npm:^2.0.1, brace-expansion@npm:^2.0.2":
|
||||
version: 2.0.2
|
||||
resolution: "brace-expansion@npm:2.0.2"
|
||||
dependencies:
|
||||
balanced-match: "npm:^1.0.0"
|
||||
checksum: 10/a61e7cd2e8a8505e9f0036b3b6108ba5e926b4b55089eeb5550cd04a471fe216c96d4fe7e4c7f995c728c554ae20ddfc4244cad10aef255e72b62930afd233d1
|
||||
checksum: 10/01dff195e3646bc4b0d27b63d9bab84d2ebc06121ff5013ad6e5356daa5a9d6b60fa26cf73c74797f2dc3fbec112af13578d51f75228c1112b26c790a87b0488
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -19834,9 +19964,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"chalk@npm:^5.0.1, chalk@npm:^5.3.0, chalk@npm:^5.4.1":
|
||||
version: 5.4.1
|
||||
resolution: "chalk@npm:5.4.1"
|
||||
checksum: 10/29df3ffcdf25656fed6e95962e2ef86d14dfe03cd50e7074b06bad9ffbbf6089adbb40f75c00744d843685c8d008adaf3aed31476780312553caf07fa86e5bc7
|
||||
version: 5.6.2
|
||||
resolution: "chalk@npm:5.6.2"
|
||||
checksum: 10/1b2f48f6fba1370670d5610f9cd54c391d6ede28f4b7062dd38244ea5768777af72e5be6b74fb6c6d54cb84c4a2dff3f3afa9b7cb5948f7f022cfd3d087989e0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -20152,10 +20282,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"cjs-module-lexer@npm:^1.2.2":
|
||||
version: 1.4.3
|
||||
resolution: "cjs-module-lexer@npm:1.4.3"
|
||||
checksum: 10/d2b92f919a2dedbfd61d016964fce8da0035f827182ed6839c97cac56e8a8077cfa6a59388adfe2bc588a19cef9bbe830d683a76a6e93c51f65852062cfe2591
|
||||
"cjs-module-lexer@npm:^2.2.0":
|
||||
version: 2.2.0
|
||||
resolution: "cjs-module-lexer@npm:2.2.0"
|
||||
checksum: 10/fc8eb5c1919504366d8260a150d93c4e857740e770467dc59ca0cc34de4b66c93075559a5af65618f359187866b1be40e036f4e1a1bab2f1e06001c216415f74
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -21781,9 +21911,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"dayjs@npm:^1.11.13, dayjs@npm:^1.11.18":
|
||||
version: 1.11.18
|
||||
resolution: "dayjs@npm:1.11.18"
|
||||
checksum: 10/7d29a90834cf4da2feb437c2f34b8235c3f94493a06d2f1bf9f506f1fa49eadf796f26e1d685b9fe8cb5e75ce6ee067825115e196f1af3d07b3552ff857bfc39
|
||||
version: 1.11.20
|
||||
resolution: "dayjs@npm:1.11.20"
|
||||
checksum: 10/5347533f21a55b8bb1b1ef559be9b805514c3a8fb7e68b75fb7e73808131c59e70909c073aa44ce8a0d159195cd110cdd4081cf87ab96cb06fee3edacae791c6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -22271,14 +22401,14 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"dompurify@npm:^3.2.5, dompurify@npm:^3.3.0":
|
||||
version: 3.3.0
|
||||
resolution: "dompurify@npm:3.3.0"
|
||||
version: 3.3.3
|
||||
resolution: "dompurify@npm:3.3.3"
|
||||
dependencies:
|
||||
"@types/trusted-types": "npm:^2.0.7"
|
||||
dependenciesMeta:
|
||||
"@types/trusted-types":
|
||||
optional: true
|
||||
checksum: 10/d8782b10a0454344476936c91038d06c9450b3e3ada2ceb8f722525e6b54e64d847939b9f35bf385facd4139f0a2eaf7f5553efce351f8e9295620570875f002
|
||||
checksum: 10/4cc9c539ed7136d46c6577613b8e20871c2b6165db01dfbd2a3c11c75f9e339c496ac6519a1c3190115def8cadae3720bef0417fc43fa28802c7407bab174da9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -23937,7 +24067,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"file-type@npm:21.3.0, file-type@npm:^21.0.0":
|
||||
"file-type@npm:21.3.0":
|
||||
version: 21.3.0
|
||||
resolution: "file-type@npm:21.3.0"
|
||||
dependencies:
|
||||
@@ -23949,6 +24079,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"file-type@npm:^21.0.0":
|
||||
version: 21.3.3
|
||||
resolution: "file-type@npm:21.3.3"
|
||||
dependencies:
|
||||
"@tokenizer/inflate": "npm:^0.4.1"
|
||||
strtok3: "npm:^10.3.4"
|
||||
token-types: "npm:^6.1.1"
|
||||
uint8array-extras: "npm:^1.4.0"
|
||||
checksum: 10/7a900a89b7e9f65cfff4d489bc4ad9311d749dd3bae3da753b681dbd1d4bf5fff204c0fa78e620076ccbbc1225f0e25330cd5653342bc78bf6b272015153ea23
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"file-uri-to-path@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "file-uri-to-path@npm:1.0.0"
|
||||
@@ -24567,10 +24709,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"get-east-asian-width@npm:^1.0.0":
|
||||
version: 1.3.0
|
||||
resolution: "get-east-asian-width@npm:1.3.0"
|
||||
checksum: 10/8e8e779eb28701db7fdb1c8cab879e39e6ae23f52dadd89c8aed05869671cee611a65d4f8557b83e981428623247d8bc5d0c7a4ef3ea7a41d826e73600112ad8
|
||||
"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.3.1":
|
||||
version: 1.5.0
|
||||
resolution: "get-east-asian-width@npm:1.5.0"
|
||||
checksum: 10/60bc34cd1e975055ab99f0f177e31bed3e516ff7cee9c536474383954a976abaa6b94a51d99ad158ef1e372790fa096cab7d07f166bb0778f6587954c0fbe946
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -25854,14 +25996,26 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"import-in-the-middle@npm:^2, import-in-the-middle@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "import-in-the-middle@npm:2.0.0"
|
||||
version: 2.0.6
|
||||
resolution: "import-in-the-middle@npm:2.0.6"
|
||||
dependencies:
|
||||
acorn: "npm:^8.14.0"
|
||||
acorn: "npm:^8.15.0"
|
||||
acorn-import-attributes: "npm:^1.9.5"
|
||||
cjs-module-lexer: "npm:^1.2.2"
|
||||
module-details-from-path: "npm:^1.0.3"
|
||||
checksum: 10/badb8359552f1e9fedc8569299dd1937e802256ce0fe6aa9cb348bca6f217f06e16a3ca46f889bfcb66028a096a1956674d257de9e809db4271ca0e508521c30
|
||||
cjs-module-lexer: "npm:^2.2.0"
|
||||
module-details-from-path: "npm:^1.0.4"
|
||||
checksum: 10/8be80d7f2d4ad34e5eb1082925ee2e90844edb65359cad0f5d8e934a09fafeca10e66f50d0b07570bd6b877ff678755d3c2d36d05258cc3541e39fa6aae6ae56
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"import-in-the-middle@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "import-in-the-middle@npm:3.0.0"
|
||||
dependencies:
|
||||
acorn: "npm:^8.15.0"
|
||||
acorn-import-attributes: "npm:^1.9.5"
|
||||
cjs-module-lexer: "npm:^2.2.0"
|
||||
module-details-from-path: "npm:^1.0.4"
|
||||
checksum: 10/0bf1f22d00a080e7f651db8c5d136aa4aca6829397769f22fb544a67d9117b1c78590f180a690eb19eb0cfb4a558beac66b6508d346cccc91e1e5f75c934e9de
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -26245,11 +26399,11 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"is-fullwidth-code-point@npm:^5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "is-fullwidth-code-point@npm:5.0.0"
|
||||
version: 5.1.0
|
||||
resolution: "is-fullwidth-code-point@npm:5.1.0"
|
||||
dependencies:
|
||||
get-east-asian-width: "npm:^1.0.0"
|
||||
checksum: 10/8dfb2d2831b9e87983c136f5c335cd9d14c1402973e357a8ff057904612ed84b8cba196319fabedf9aefe4639e14fe3afe9d9966d1d006ebeb40fe1fed4babe5
|
||||
get-east-asian-width: "npm:^1.3.1"
|
||||
checksum: 10/4700d8a82cb71bd2a2955587b2823c36dc4660eadd4047bfbd070821ddbce8504fc5f9b28725567ecddf405b1e06c6692c9b719f65df6af9ec5262bc11393a6a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -27089,13 +27243,13 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"katex@npm:^0.16.0, katex@npm:^0.16.22, katex@npm:^0.16.27":
|
||||
version: 0.16.27
|
||||
resolution: "katex@npm:0.16.27"
|
||||
version: 0.16.38
|
||||
resolution: "katex@npm:0.16.38"
|
||||
dependencies:
|
||||
commander: "npm:^8.3.0"
|
||||
bin:
|
||||
katex: cli.js
|
||||
checksum: 10/7666ae11c6c1238626bffaf1a526af6ff679114d62293bf2f0e29f8a34d8e961c0edcb686c5b628158ec92a143b4bef5d83539c81b29a63c7dcf0bdb4544eec9
|
||||
checksum: 10/e9103def114d9d08ab216864e66b68e6f50a6360fdc5aa29d8edeee430e1618dd7551b9f080e9c591b3ee24c18fe6910b8fe0c89c7c4b1109abd2b63e223fbc5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -27986,9 +28140,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"lru-cache@npm:^11.0.0":
|
||||
version: 11.1.0
|
||||
resolution: "lru-cache@npm:11.1.0"
|
||||
checksum: 10/5011011675ca98428902de774d0963b68c3a193cd959347cb63b781dad4228924124afab82159fd7b8b4db18285d9aff462b877b8f6efd2b41604f806c1d9db4
|
||||
version: 11.2.7
|
||||
resolution: "lru-cache@npm:11.2.7"
|
||||
checksum: 10/fbff4b8dee8189dde9b52cdfb3ea89b4c9cec094c1538cd30d1f47299477ff312efdb35f7994477ec72328f8e754e232b26a143feda1bd1f79ff22da6664d2c5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -29127,11 +29281,11 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"minimatch@npm:^9.0.0, minimatch@npm:^9.0.3, minimatch@npm:^9.0.4, minimatch@npm:^9.0.5":
|
||||
version: 9.0.6
|
||||
resolution: "minimatch@npm:9.0.6"
|
||||
version: 9.0.9
|
||||
resolution: "minimatch@npm:9.0.9"
|
||||
dependencies:
|
||||
brace-expansion: "npm:^5.0.2"
|
||||
checksum: 10/c7a46134aaf349f386de9a3f6c5b48c53bc3a4e2ef4b8b6365184504e28cc31cc261a388e181648cbc756b40e213dbce115c8087a47eff8f54ee28d62bc17b08
|
||||
brace-expansion: "npm:^2.0.2"
|
||||
checksum: 10/b91fad937deaffb68a45a2cb731ff3cff1c3baf9b6469c879477ed16f15c8f4ce39d63a3f75c2455107c2fdff0f3ab597d97dc09e2e93b883aafcf926ef0c8f9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -29234,9 +29388,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2":
|
||||
version: 7.1.2
|
||||
resolution: "minipass@npm:7.1.2"
|
||||
checksum: 10/c25f0ee8196d8e6036661104bacd743785b2599a21de5c516b32b3fa2b83113ac89a2358465bc04956baab37ffb956ae43be679b2262bf7be15fce467ccd7950
|
||||
version: 7.1.3
|
||||
resolution: "minipass@npm:7.1.3"
|
||||
checksum: 10/175e4d5e20980c3cd316ae82d2c031c42f6c746467d8b1905b51060a0ba4461441a0c25bb67c025fd9617f9a3873e152c7b543c6b5ac83a1846be8ade80dffd6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -29280,14 +29434,14 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"mlly@npm:^1.4.2, mlly@npm:^1.7.1, mlly@npm:^1.7.4":
|
||||
version: 1.7.4
|
||||
resolution: "mlly@npm:1.7.4"
|
||||
version: 1.8.1
|
||||
resolution: "mlly@npm:1.8.1"
|
||||
dependencies:
|
||||
acorn: "npm:^8.14.0"
|
||||
pathe: "npm:^2.0.1"
|
||||
pkg-types: "npm:^1.3.0"
|
||||
ufo: "npm:^1.5.4"
|
||||
checksum: 10/1b36163d38c2331f8ae480e6a11da3d15927a2148d729fcd9df6d0059ca74869aa693931bd1f762f82eb534b84c921bdfbc036eb0e4da4faeb55f1349d254f35
|
||||
acorn: "npm:^8.16.0"
|
||||
pathe: "npm:^2.0.3"
|
||||
pkg-types: "npm:^1.3.1"
|
||||
ufo: "npm:^1.6.3"
|
||||
checksum: 10/8e424f0615d09adfb7d59ad8f0c8245df275cd05e483a4631a1b2c5dd7e09913a9ce8182bc1562d569941ecee25ab03f4429284265471f562da1dd308008e237
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -29433,7 +29587,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"multer@npm:2.0.2, multer@npm:^2.0.2":
|
||||
"multer@npm:2.0.2":
|
||||
version: 2.0.2
|
||||
resolution: "multer@npm:2.0.2"
|
||||
dependencies:
|
||||
@@ -29448,6 +29602,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"multer@npm:^2.0.2":
|
||||
version: 2.1.1
|
||||
resolution: "multer@npm:2.1.1"
|
||||
dependencies:
|
||||
append-field: "npm:^1.0.0"
|
||||
busboy: "npm:^1.6.0"
|
||||
concat-stream: "npm:^2.0.0"
|
||||
type-is: "npm:^1.6.18"
|
||||
checksum: 10/fb22868caaed37d725715c14c60b740b81665265da3a026bb61954414f65b99f76b360128413b8a2a7cc1a95ecae28a42bf831fe172bb79682d19ec105b556bd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"multicast-dns@npm:^7.2.5":
|
||||
version: 7.2.5
|
||||
resolution: "multicast-dns@npm:7.2.5"
|
||||
@@ -30483,9 +30649,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"p-map@npm:^7.0.2, p-map@npm:^7.0.3":
|
||||
version: 7.0.3
|
||||
resolution: "p-map@npm:7.0.3"
|
||||
checksum: 10/2ef48ccfc6dd387253d71bf502604f7893ed62090b2c9d73387f10006c342606b05233da0e4f29388227b61eb5aeface6197e166520c465c234552eeab2fe633
|
||||
version: 7.0.4
|
||||
resolution: "p-map@npm:7.0.4"
|
||||
checksum: 10/ef48c3b2e488f31c693c9fcc0df0ef76518cf6426a495cf9486ebbb0fd7f31aef7f90e96f72e0070c0ff6e3177c9318f644b512e2c29e3feee8d7153fcb6782e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -30825,12 +30991,12 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"path-scurry@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "path-scurry@npm:2.0.0"
|
||||
version: 2.0.2
|
||||
resolution: "path-scurry@npm:2.0.2"
|
||||
dependencies:
|
||||
lru-cache: "npm:^11.0.0"
|
||||
minipass: "npm:^7.1.2"
|
||||
checksum: 10/285ae0c2d6c34ae91dc1d5378ede21981c9a2f6de1ea9ca5a88b5a270ce9763b83dbadc7a324d512211d8d36b0c540427d3d0817030849d97a60fa840a2c59ec
|
||||
checksum: 10/2b4257422bcb870a4c2d205b3acdbb213a72f5e2250f61c80f79c9d014d010f82bdf8584441612c8e1fa4eb098678f5704a66fa8377d72646bad4be38e57a2c3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -30848,7 +31014,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"path-to-regexp@npm:8.3.0, path-to-regexp@npm:^8.0.0, path-to-regexp@npm:^8.3.0":
|
||||
"path-to-regexp@npm:8.3.0":
|
||||
version: 8.3.0
|
||||
resolution: "path-to-regexp@npm:8.3.0"
|
||||
checksum: 10/568f148fc64f5fd1ecebf44d531383b28df924214eabf5f2570dce9587a228e36c37882805ff02d71c6209b080ea3ee6a4d2b712b5df09741b67f1f3cf91e55a
|
||||
@@ -30862,6 +31028,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"path-to-regexp@npm:^8.0.0, path-to-regexp@npm:^8.3.0":
|
||||
version: 8.4.0
|
||||
resolution: "path-to-regexp@npm:8.4.0"
|
||||
checksum: 10/6864561ceacaece330a2213c6eb21505519673a65b4249e0e5d518984528f93b302b8bf85ccafc4f9d7f359f919d8b118dc00fdb0b26ded3d21e8802cffdfcb8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"path-to-regexp@npm:~0.1.12":
|
||||
version: 0.1.12
|
||||
resolution: "path-to-regexp@npm:0.1.12"
|
||||
@@ -31076,7 +31249,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pkg-types@npm:^1.2.0, pkg-types@npm:^1.3.0, pkg-types@npm:^1.3.1":
|
||||
"pkg-types@npm:^1.2.0, pkg-types@npm:^1.3.1":
|
||||
version: 1.3.1
|
||||
resolution: "pkg-types@npm:1.3.1"
|
||||
dependencies:
|
||||
@@ -31651,11 +31824,11 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"pretty-ms@npm:^9.0.0, pretty-ms@npm:^9.2.0":
|
||||
version: 9.2.0
|
||||
resolution: "pretty-ms@npm:9.2.0"
|
||||
version: 9.3.0
|
||||
resolution: "pretty-ms@npm:9.3.0"
|
||||
dependencies:
|
||||
parse-ms: "npm:^4.0.0"
|
||||
checksum: 10/a65a1d81560867f4f7128862fdbf0e1c2d3c5607bf75cae7758bf8111e2c4b744be46e084704125a38ba918bb43defa7a53aaff0f48c5c2d95367d3148c980d9
|
||||
checksum: 10/beb4e04dc17071885b827e3f33d36be279791f2f36a8c29a45c77e59979dad79a5d7e5211922c72a3f6f109bb64a707d70fcdba6746e077122afcd88ce202e98
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -33431,9 +33604,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"sax@npm:>=0.6.0, sax@npm:^1.2.4, sax@npm:^1.4.1":
|
||||
version: 1.4.1
|
||||
resolution: "sax@npm:1.4.1"
|
||||
checksum: 10/b1c784b545019187b53a0c28edb4f6314951c971e2963a69739c6ce222bfbc767e54d320e689352daba79b7d5e06d22b5d7113b99336219d6e93718e2f99d335
|
||||
version: 1.5.0
|
||||
resolution: "sax@npm:1.5.0"
|
||||
checksum: 10/9012ff37dda7a7ac5da45db2143b04036103e8bef8d586c3023afd5df6caf0ebd7f38017eee344ad2e2247eded7d38e9c42cf291d8dd91781352900ac0fd2d9f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -33523,7 +33696,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"semver@npm:7.7.4, semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.1.3, semver@npm:^7.2.1, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3, semver@npm:^7.7.1, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:~7.7.3":
|
||||
"semver@npm:7.7.4, semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.1.3, semver@npm:^7.2.1, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3, semver@npm:^7.7.1, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:^7.7.4, semver@npm:~7.7.3":
|
||||
version: 7.7.4
|
||||
resolution: "semver@npm:7.7.4"
|
||||
bin:
|
||||
@@ -34511,11 +34684,11 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0":
|
||||
version: 7.1.0
|
||||
resolution: "strip-ansi@npm:7.1.0"
|
||||
version: 7.2.0
|
||||
resolution: "strip-ansi@npm:7.2.0"
|
||||
dependencies:
|
||||
ansi-regex: "npm:^6.0.1"
|
||||
checksum: 10/475f53e9c44375d6e72807284024ac5d668ee1d06010740dec0b9744f2ddf47de8d7151f80e5f6190fc8f384e802fdf9504b76a7e9020c9faee7103623338be2
|
||||
ansi-regex: "npm:^6.2.2"
|
||||
checksum: 10/96da3bc6d73cfba1218625a3d66cf7d37a69bf0920d8735b28f9eeaafcdb6c1fe8440e1ae9eb1ba0ca355dbe8702da872e105e2e939fa93e7851b3cb5dd7d316
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -35144,9 +35317,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"tinyexec@npm:^1.0.0, tinyexec@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "tinyexec@npm:1.0.1"
|
||||
checksum: 10/1f3c3281912d4ab168e067baf46627bb85a803eba0bcea113bba9fe8bdfdcc279cad08052a600d4b8fb603dd57e1af0c500e50a5e7e6b29b2574c88556f41fa6
|
||||
version: 1.0.4
|
||||
resolution: "tinyexec@npm:1.0.4"
|
||||
checksum: 10/ccebe4044eef6fa5050929df7862fda70b4fb700f15d94aef8ae6109b9d194dbc3a990125d99944fd25b90fe2115e1927f055b909a604c571a81b647ede5757a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -35676,10 +35849,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ufo@npm:^1.5.4":
|
||||
version: 1.6.1
|
||||
resolution: "ufo@npm:1.6.1"
|
||||
checksum: 10/088a68133b93af183b093e5a8730a40fe7fd675d3dc0656ea7512f180af45c92300c294f14d4d46d4b2b553e3e52d3b13d4856b9885e620e7001edf85531234e
|
||||
"ufo@npm:^1.5.4, ufo@npm:^1.6.3":
|
||||
version: 1.6.3
|
||||
resolution: "ufo@npm:1.6.3"
|
||||
checksum: 10/79803984f3e414567273a666183d6a50d1bec0d852100a98f55c1e393cb705e3b88033e04029dd651714e6eec99e1b00f54fdc13f32404968251a16f8898cfe5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -37022,8 +37195,8 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"ws@npm:^8.17.1, ws@npm:^8.18.0, ws@npm:^8.18.2, ws@npm:^8.18.3":
|
||||
version: 8.19.0
|
||||
resolution: "ws@npm:8.19.0"
|
||||
version: 8.20.0
|
||||
resolution: "ws@npm:8.20.0"
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: ">=5.0.2"
|
||||
@@ -37032,7 +37205,7 @@ __metadata:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
checksum: 10/26e4901e93abaf73af9f26a93707c95b4845e91a7a347ec8c569e6e9be7f9df066f6c2b817b2d685544e208207898a750b78461e6e8d810c11a370771450c31b
|
||||
checksum: 10/b7ab934b21ffdea9f25a5af5097e8c1ec7625db553bca026c5a23e35b7c236f3fb89782f2b57fab9da553864512f9aa7d245827ef998d26ffa1b2187a19a6d10
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user