Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop

This commit is contained in:
claude_code
2026-06-22 21:14:05 +03:00
45 changed files with 661 additions and 127 deletions

View File

@@ -14,6 +14,8 @@ import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler';
import {
AI_CHAT_THROTTLER,
AUTH_THROTTLER,
PAGE_TEMPLATE_THROTTLER,
PUBLIC_SHARE_AI_THROTTLER,
} from '../../integrations/throttle/throttler-names';
import { LoginDto } from './dto/login.dto';
import { AuthService } from './services/auth.service';
@@ -181,7 +183,18 @@ export class AuthController {
return this.authService.verifyUserToken(verifyUserTokenDto, workspace.id);
}
@SkipThrottle({ [AUTH_THROTTLER]: true })
// The global ThrottlerGuard applies ALL named throttlers to every route by
// default, so each non-AUTH bucket (AI chat, page template, public-share AI)
// is explicitly skipped here. collab-token is auth-guarded (JwtAuthGuard),
// per-user and client-cached, so those feature buckets are irrelevant to it;
// skipping them avoids spurious 429s when a user opens many pages in a short
// window. The AUTH bucket is skipped too for the same per-user, cached reason.
@SkipThrottle({
[AUTH_THROTTLER]: true,
[AI_CHAT_THROTTLER]: true,
[PAGE_TEMPLATE_THROTTLER]: true,
[PUBLIC_SHARE_AI_THROTTLER]: true,
})
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('collab-token')

View File

@@ -0,0 +1,81 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CreateWorkspaceDto } from './create-workspace.dto';
import { UpdateWorkspaceDto } from './update-workspace.dto';
// API-boundary validation for the workspace `name` field. The name is:
// - required, 1..64 chars (MinLength/MaxLength), trimmed on input;
// - rejected by @NoUrls when it contains a URL or a bare domain name.
// UpdateWorkspaceDto extends CreateWorkspaceDto via PartialType, so `name`
// stays optional there but inherits the same constraints when present.
async function validateCreate(payload: Record<string, unknown>) {
const dto = plainToInstance(CreateWorkspaceDto, payload);
return validate(dto as object);
}
async function validateUpdate(payload: Record<string, unknown>) {
const dto = plainToInstance(UpdateWorkspaceDto, payload);
return validate(dto as object);
}
function hasError(errors: any[], property: string, constraint?: string) {
const err = errors.find((e) => e.property === property);
if (!err) return false;
if (!constraint) return true;
return Object.keys(err.constraints ?? {}).includes(constraint);
}
describe('CreateWorkspaceDto.name validation', () => {
it('accepts a plain workspace name', async () => {
const errors = await validateCreate({ name: 'My Workspace' });
expect(hasError(errors, 'name')).toBe(false);
});
it('rejects a name containing a URL with the noUrls error', async () => {
const errors = await validateCreate({
name: 'Visit https://evil.com now',
});
expect(hasError(errors, 'name', 'noUrls')).toBe(true);
});
it('rejects a name containing a bare domain with the noUrls error', async () => {
const errors = await validateCreate({ name: 'evil.com workspace' });
expect(hasError(errors, 'name', 'noUrls')).toBe(true);
});
it('rejects an empty name with a minLength error', async () => {
const errors = await validateCreate({ name: '' });
expect(hasError(errors, 'name', 'minLength')).toBe(true);
});
it('accepts exactly 64 characters', async () => {
const errors = await validateCreate({ name: 'a'.repeat(64) });
expect(hasError(errors, 'name')).toBe(false);
});
it('rejects 65 characters with a maxLength error', async () => {
const errors = await validateCreate({ name: 'a'.repeat(65) });
expect(hasError(errors, 'name', 'maxLength')).toBe(true);
});
});
describe('UpdateWorkspaceDto.name validation (inherited)', () => {
it('accepts a plain workspace name', async () => {
const errors = await validateUpdate({ name: 'My Workspace' });
expect(hasError(errors, 'name')).toBe(false);
});
it('rejects a name containing a URL with the noUrls error', async () => {
const errors = await validateUpdate({
name: 'Visit https://evil.com now',
});
expect(hasError(errors, 'name', 'noUrls')).toBe(true);
});
it('accepts an omitted name (optional via PartialType)', async () => {
const errors = await validateUpdate({});
expect(hasError(errors, 'name')).toBe(false);
});
});

View File

@@ -6,11 +6,13 @@ import {
MinLength,
} from 'class-validator';
import { Transform, TransformFnParams } from 'class-transformer';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class CreateWorkspaceDto {
@MinLength(1)
@MaxLength(64)
@IsString()
@NoUrls()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;

View File

@@ -86,7 +86,9 @@ export class AiSettingsController {
) {
this.assertAdmin(user, workspace);
await this.aiSettingsService.reindex(workspace.id);
// Return refreshed masked settings so the client can update the counter.
// Indexing runs as an async background job, so these masked settings carry
// the PRE-job counts (the indexed total has not climbed yet). The client
// polls this endpoint's GET counterpart to watch the counter advance.
return this.aiSettingsService.getMasked(workspace.id);
}
}

View File

@@ -8,6 +8,8 @@ import { ServiceUnavailableException } from '@nestjs/common';
*/
export class AiSttNotConfiguredException extends ServiceUnavailableException {
constructor() {
super('AI STT model not configured');
// User-facing copy: the client surfaces this 503 message verbatim in the
// dictation toast, so keep it consistent with the client's fallback copy.
super('Voice dictation is not configured');
}
}