mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-24 21:27:03 +08:00
fix(server): improve self hosted usability (#15510)
fix #15505 fix #15502 fix #15496 fix #15491 #### PR Dependency Tree * **PR #15510** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added configurable delays for invitations, invite links, and document publishing by newly created accounts. - Added workspace action checks that explain blocked actions and retry timing. - BYOK setup now verifies model capabilities and saves only validated options. - **Bug Fixes** - Improved BYOK probing for chat, structured responses, tool calls, embeddings, reranking, and image generation. - Preserved probe request order and strengthened response validation. - Authentication configuration changes now reload correctly. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -18,7 +18,7 @@ export interface AuthConfig {
|
||||
allowSignupForOauth: boolean;
|
||||
requireEmailDomainVerification: boolean;
|
||||
requireEmailVerification: boolean;
|
||||
newAccountShareActionDelay: number;
|
||||
newAccountActionDelay: number;
|
||||
trustedCloudflareHeaders: boolean;
|
||||
signInRateLimit: ConfigItem<{
|
||||
ttl: number;
|
||||
@@ -56,8 +56,8 @@ defineModuleConfig('auth', {
|
||||
desc: 'Whether require email verification before accessing restricted resources(not implemented).',
|
||||
default: true,
|
||||
},
|
||||
newAccountShareActionDelay: {
|
||||
desc: 'Minimum account age in seconds before new accounts can invite members or create share links.',
|
||||
newAccountActionDelay: {
|
||||
desc: 'Minimum account age in seconds before new accounts can invite members, create invite links, or publish documents. Set to 0 to disable.',
|
||||
default: 24 * 60 * 60,
|
||||
shape: z.number().int().min(0),
|
||||
},
|
||||
|
||||
@@ -121,6 +121,12 @@ export type RuntimeWorkspaceInviteQuotaUsage = {
|
||||
targetDomains: RuntimeQuotaTargetDomainInput[];
|
||||
};
|
||||
|
||||
export type RuntimeWorkspaceActionDecision = {
|
||||
allowed: boolean;
|
||||
retryAfterSeconds?: number;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type RuntimeInviteAbuseAction =
|
||||
| 'ban_actor'
|
||||
| 'quarantine_actor'
|
||||
@@ -213,6 +219,10 @@ export type RuntimeMailDeliveryQuotaDecision = {
|
||||
};
|
||||
|
||||
type RuntimeQuotaMethods = RuntimeInstance & {
|
||||
evaluateWorkspaceActionV1(
|
||||
actorUserId: string,
|
||||
workspaceId: string
|
||||
): Promise<RuntimeWorkspaceActionDecision>;
|
||||
assertWorkspaceInviteQuotaV1(
|
||||
input: RuntimeWorkspaceInviteQuotaInput
|
||||
): Promise<NativeRuntimeWorkspaceInviteQuotaDecision>;
|
||||
@@ -328,6 +338,7 @@ export class BackendRuntimeProvider
|
||||
!updates.copilot &&
|
||||
!updates.crypto &&
|
||||
!updates.db &&
|
||||
!updates.auth &&
|
||||
!updates.indexer &&
|
||||
!updates.storages
|
||||
) {
|
||||
@@ -537,6 +548,12 @@ export class BackendRuntimeProvider
|
||||
);
|
||||
}
|
||||
|
||||
async evaluateWorkspaceActionV1(actorUserId: string, workspaceId: string) {
|
||||
return await this.measured('evaluateWorkspaceActionV1', rt =>
|
||||
this.quotaRuntime(rt).evaluateWorkspaceActionV1(actorUserId, workspaceId)
|
||||
);
|
||||
}
|
||||
|
||||
async commitWorkspaceInviteQuotaV1(
|
||||
reservationId: string,
|
||||
usage: RuntimeWorkspaceInviteQuotaUsage
|
||||
|
||||
@@ -15,6 +15,9 @@ import { Mockers } from '../../../__tests__/mocks';
|
||||
import { Config } from '../../../base';
|
||||
import { ActionForbidden, TooManyRequest } from '../../../base/error';
|
||||
import { Models, WorkspaceRole } from '../../../models';
|
||||
import { BackendRuntimeProvider } from '../../backend-runtime';
|
||||
import { EntitlementService } from '../../entitlement';
|
||||
import { QuotaService } from '../../quota';
|
||||
import {
|
||||
getAbuseRequestSource,
|
||||
InviteAbuseDispositionService,
|
||||
@@ -23,6 +26,7 @@ import {
|
||||
|
||||
let app: TestingApp;
|
||||
const quota = {
|
||||
assertWorkspaceActionAllowed: Sinon.stub(),
|
||||
assertWorkspaceInviteQuota: Sinon.stub(),
|
||||
commitWorkspaceInviteQuota: Sinon.stub(),
|
||||
releaseWorkspaceInviteQuota: Sinon.stub(),
|
||||
@@ -41,6 +45,7 @@ test.before(async () => {
|
||||
});
|
||||
|
||||
test.beforeEach(() => {
|
||||
quota.assertWorkspaceActionAllowed.reset();
|
||||
quota.assertWorkspaceInviteQuota.reset();
|
||||
quota.commitWorkspaceInviteQuota.reset();
|
||||
quota.releaseWorkspaceInviteQuota.reset();
|
||||
@@ -345,8 +350,8 @@ test('workspace quarantine blocks invite link creation', async t => {
|
||||
updated_at = now()
|
||||
`;
|
||||
|
||||
const previousDelay = config.auth.newAccountShareActionDelay;
|
||||
config.auth.newAccountShareActionDelay = 0;
|
||||
const previousDelay = config.auth.newAccountActionDelay;
|
||||
config.auth.newAccountActionDelay = 0;
|
||||
try {
|
||||
await app.login(owner);
|
||||
await t.throwsAsync(
|
||||
@@ -359,32 +364,58 @@ test('workspace quarantine blocks invite link creation', async t => {
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
config.auth.newAccountShareActionDelay = previousDelay;
|
||||
config.auth.newAccountActionDelay = previousDelay;
|
||||
}
|
||||
});
|
||||
|
||||
test('domain workspace name blocks invite link creation', async t => {
|
||||
const config = app.get(Config);
|
||||
test('workspace action admission applies exemption before content policy', async t => {
|
||||
const db = app.get(PrismaClient);
|
||||
const inviteQuota = new InviteQuotaAssertService(
|
||||
app.get(Config),
|
||||
app.get(QuotaService),
|
||||
app.get(BackendRuntimeProvider),
|
||||
app.get(InviteAbuseDispositionService)
|
||||
);
|
||||
const owner = await app.create(Mockers.User);
|
||||
await db.user.update({
|
||||
where: { id: owner.id },
|
||||
data: { createdAt: new Date() },
|
||||
});
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner,
|
||||
name: 'Join example.com',
|
||||
});
|
||||
|
||||
const previousDelay = config.auth.newAccountShareActionDelay;
|
||||
config.auth.newAccountShareActionDelay = 0;
|
||||
try {
|
||||
await app.login(owner);
|
||||
await t.throwsAsync(
|
||||
app.gql({
|
||||
query: createInviteLinkMutation,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
|
||||
},
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
config.auth.newAccountShareActionDelay = previousDelay;
|
||||
}
|
||||
await t.throwsAsync(
|
||||
inviteQuota.assertWorkspaceActionAllowed({
|
||||
actorUserId: owner.id,
|
||||
workspaceId: workspace.id,
|
||||
action: 'inviteMember',
|
||||
}),
|
||||
{ instanceOf: ActionForbidden }
|
||||
);
|
||||
|
||||
await app.get(EntitlementService).upsertAdminGrant({
|
||||
targetType: 'user',
|
||||
targetId: owner.id,
|
||||
plan: 'pro',
|
||||
});
|
||||
await t.notThrowsAsync(
|
||||
inviteQuota.assertWorkspaceActionAllowed({
|
||||
actorUserId: owner.id,
|
||||
workspaceId: workspace.id,
|
||||
action: 'inviteMember',
|
||||
})
|
||||
);
|
||||
|
||||
await app.login(owner);
|
||||
await t.throwsAsync(
|
||||
app.gql({
|
||||
query: createInviteLinkMutation,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -39,14 +39,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export function canUserExecuteLimitedActions(
|
||||
user: { createdAt: Date },
|
||||
minimumAccountAgeMs: number
|
||||
) {
|
||||
if (minimumAccountAgeMs <= 0) return true;
|
||||
return Date.now() - user.createdAt.getTime() >= minimumAccountAgeMs;
|
||||
}
|
||||
|
||||
function parseAsn(value: string | undefined) {
|
||||
if (!value) {
|
||||
return;
|
||||
@@ -237,6 +229,28 @@ export class InviteQuotaAssertService {
|
||||
private readonly disposition: InviteAbuseDispositionService
|
||||
) {}
|
||||
|
||||
async assertWorkspaceActionAllowed(input: {
|
||||
actorUserId: string;
|
||||
workspaceId: string;
|
||||
action: 'inviteMember' | 'createInviteLink' | 'publishDoc';
|
||||
docId?: string;
|
||||
}) {
|
||||
const decision = await this.runtime.evaluateWorkspaceActionV1(
|
||||
input.actorUserId,
|
||||
input.workspaceId
|
||||
);
|
||||
if (decision.allowed) return;
|
||||
|
||||
this.logger.warn('Workspace action rejected', {
|
||||
...input,
|
||||
reason: decision.reason,
|
||||
retryAfter: decision.retryAfterSeconds,
|
||||
});
|
||||
throw new ActionForbidden(
|
||||
'This feature is temporarily unavailable for you.'
|
||||
);
|
||||
}
|
||||
|
||||
async assertWorkspaceInviteQuota(input: {
|
||||
actorUserId: string;
|
||||
workspaceId: string;
|
||||
@@ -384,7 +398,11 @@ export class InviteQuotaAssertService {
|
||||
private mapDecision(
|
||||
decision: RuntimeWorkspaceInviteQuotaDecision
|
||||
): UserFriendlyError {
|
||||
if (decision.reason === 'abuse_subject' || decision.actionRequired) {
|
||||
if (
|
||||
decision.reason === 'abuse_subject' ||
|
||||
decision.reason === 'new_account_action_delay' ||
|
||||
decision.actionRequired
|
||||
) {
|
||||
return new ActionForbidden('This feature is temporarily unavailable.');
|
||||
}
|
||||
return new TooManyRequest();
|
||||
|
||||
@@ -15,9 +15,7 @@ import { Prisma, PrismaClient } from '@prisma/client';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
|
||||
import {
|
||||
ActionForbidden,
|
||||
Cache,
|
||||
Config,
|
||||
DocActionDenied,
|
||||
DocDefaultRoleCanNotBeOwner,
|
||||
DocNotFound,
|
||||
@@ -46,7 +44,7 @@ import {
|
||||
PermissionAccess,
|
||||
} from '../../permission';
|
||||
import { PublicUserType, WorkspaceUserType } from '../../user';
|
||||
import { canUserExecuteLimitedActions } from '../abuse';
|
||||
import { InviteQuotaAssertService } from '../abuse';
|
||||
import { DocGrantsService } from '../doc-grants';
|
||||
import { WorkspaceType } from '../types';
|
||||
import { TimeBucket, TimeWindow } from './analytics-types';
|
||||
@@ -302,51 +300,10 @@ export class WorkspaceDocResolver {
|
||||
private readonly models: Models,
|
||||
private readonly cache: Cache,
|
||||
private readonly event: EventBus,
|
||||
private readonly config: Config,
|
||||
private readonly runtime: BackendRuntimeProvider
|
||||
private readonly runtime: BackendRuntimeProvider,
|
||||
private readonly inviteQuota: InviteQuotaAssertService
|
||||
) {}
|
||||
|
||||
private async assertCanShare(
|
||||
userId: string,
|
||||
context: { workspaceId: string; docId: string; action: 'publishDoc' }
|
||||
) {
|
||||
if (await this.runtime.isInviteAbuseUserQuarantinedOrBanned(userId)) {
|
||||
this.logger.warn('Share action blocked for quarantined actor', {
|
||||
userId,
|
||||
...context,
|
||||
});
|
||||
throw new ActionForbidden(
|
||||
'This feature is temporarily unavailable for you.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
await this.runtime.isInviteAbuseWorkspaceQuarantined(context.workspaceId)
|
||||
) {
|
||||
this.logger.warn('Share action blocked for quarantined workspace', {
|
||||
userId,
|
||||
...context,
|
||||
});
|
||||
throw new ActionForbidden(
|
||||
'This feature is temporarily unavailable for you.'
|
||||
);
|
||||
}
|
||||
const user = await this.models.user.get(userId);
|
||||
const newAccountAgeMs = this.config.auth.newAccountShareActionDelay * 1000;
|
||||
if (!user || !canUserExecuteLimitedActions(user, newAccountAgeMs)) {
|
||||
this.logger.warn('Share action blocked for new account', {
|
||||
userId,
|
||||
email: user?.email,
|
||||
createdAt: user?.createdAt,
|
||||
accountAgeMs: user ? Date.now() - user.createdAt.getTime() : null,
|
||||
minimumAccountAgeMs: newAccountAgeMs,
|
||||
...context,
|
||||
});
|
||||
throw new ActionForbidden(
|
||||
'This feature is temporarily unavailable for you.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ResolveField(() => WorkspaceDocMeta, {
|
||||
description: 'Cloud page metadata of workspace',
|
||||
complexity: 2,
|
||||
@@ -475,7 +432,8 @@ export class WorkspaceDocResolver {
|
||||
}
|
||||
|
||||
await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish');
|
||||
await this.assertCanShare(user.id, {
|
||||
await this.inviteQuota.assertWorkspaceActionAllowed({
|
||||
actorUserId: user.id,
|
||||
workspaceId,
|
||||
docId,
|
||||
action: 'publishDoc',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Context,
|
||||
@@ -39,7 +38,6 @@ import {
|
||||
import type { GraphqlContext } from '../../../base/graphql';
|
||||
import { Models, type WorkspaceUserCompat } from '../../../models';
|
||||
import { CurrentUser, Public } from '../../auth';
|
||||
import { BackendRuntimeProvider } from '../../backend-runtime';
|
||||
import { containsUrlOrDomain } from '../../content-policy';
|
||||
import {
|
||||
PermissionAccess,
|
||||
@@ -49,11 +47,7 @@ import {
|
||||
import { QuotaService } from '../../quota';
|
||||
import { UserType } from '../../user';
|
||||
import { validators } from '../../utils/validators';
|
||||
import {
|
||||
canUserExecuteLimitedActions,
|
||||
getAbuseRequestSource,
|
||||
InviteQuotaAssertService,
|
||||
} from '../abuse';
|
||||
import { getAbuseRequestSource, InviteQuotaAssertService } from '../abuse';
|
||||
import { WorkspaceService } from '../service';
|
||||
import {
|
||||
InvitationType,
|
||||
@@ -92,8 +86,6 @@ function aggregateTargetDomains(candidates: InviteCandidate[]) {
|
||||
*/
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceMemberResolver {
|
||||
private readonly logger = new Logger(WorkspaceMemberResolver.name);
|
||||
|
||||
constructor(
|
||||
private readonly cache: Cache,
|
||||
private readonly event: EventBus,
|
||||
@@ -105,55 +97,9 @@ export class WorkspaceMemberResolver {
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly quota: QuotaService,
|
||||
private readonly config: Config,
|
||||
private readonly inviteQuota: InviteQuotaAssertService,
|
||||
private readonly runtime: BackendRuntimeProvider
|
||||
private readonly inviteQuota: InviteQuotaAssertService
|
||||
) {}
|
||||
|
||||
private async assertCanInviteOrShare(
|
||||
userId: string,
|
||||
context: {
|
||||
workspaceId: string;
|
||||
action: 'createInviteLink';
|
||||
}
|
||||
) {
|
||||
if (await this.runtime.isInviteAbuseUserQuarantinedOrBanned(userId)) {
|
||||
this.logger.warn('Share action blocked for quarantined actor', {
|
||||
userId,
|
||||
...context,
|
||||
});
|
||||
throw new ActionForbidden(
|
||||
'This feature is temporarily unavailable for you.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
await this.runtime.isInviteAbuseWorkspaceQuarantined(context.workspaceId)
|
||||
) {
|
||||
this.logger.warn('Share action blocked for quarantined workspace', {
|
||||
userId,
|
||||
...context,
|
||||
});
|
||||
throw new ActionForbidden(
|
||||
'This feature is temporarily unavailable for you.'
|
||||
);
|
||||
}
|
||||
// Member invites are owned by native quota; this guard stays for invite links until share/link actions migrate.
|
||||
const user = await this.models.user.get(userId);
|
||||
const newAccountAgeMs = this.config.auth.newAccountShareActionDelay * 1000;
|
||||
if (!user || !canUserExecuteLimitedActions(user, newAccountAgeMs)) {
|
||||
this.logger.warn('Share action blocked for new account', {
|
||||
userId,
|
||||
email: user?.email,
|
||||
createdAt: user?.createdAt,
|
||||
accountAgeMs: user ? Date.now() - user.createdAt.getTime() : null,
|
||||
minimumAccountAgeMs: newAccountAgeMs,
|
||||
...context,
|
||||
});
|
||||
throw new ActionForbidden(
|
||||
'This feature is temporarily unavailable for you.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertWorkspaceNameCanInvite(workspaceId: string) {
|
||||
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
|
||||
if (containsUrlOrDomain(workspace.name)) {
|
||||
@@ -287,6 +233,12 @@ export class WorkspaceMemberResolver {
|
||||
return results;
|
||||
}
|
||||
|
||||
await this.inviteQuota.assertWorkspaceActionAllowed({
|
||||
actorUserId: me.id,
|
||||
workspaceId,
|
||||
action: 'inviteMember',
|
||||
});
|
||||
|
||||
// lock to prevent concurrent invite
|
||||
const lockFlag = `invite:${workspaceId}`;
|
||||
await using lock = await this.mutex.acquire(lockFlag);
|
||||
@@ -452,7 +404,8 @@ export class WorkspaceMemberResolver {
|
||||
.user(user.id)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Users.Manage');
|
||||
await this.assertCanInviteOrShare(user.id, {
|
||||
await this.inviteQuota.assertWorkspaceActionAllowed({
|
||||
actorUserId: user.id,
|
||||
workspaceId,
|
||||
action: 'createInviteLink',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user