Compare commits

..
Author SHA1 Message Date
Salihu 9fe3732852 expand to zip imports 2026-09-10 23:25:05 +01:00
Salihu 34892f493a expand file type support 2026-09-03 17:17:12 +01:00
Salihu 8c7461c125 base64 import init 2026-09-02 18:25:45 +01:00
Salihu cd9c166927 Merge pull request #2417 from docmost/fix/checkbox-filtering
fix(ee): checkbox filtering
2026-08-23 21:21:02 +01:00
Philip Okugbe 549cf7c005 fix: skip pgvector table check when enabling AI search on turbopuffer (#2416) 2026-08-22 13:20:35 +01:00
Philip Okugbe e14f499f3d fix: pass tls to redis in throttle module (#2415) 2026-08-22 03:46:00 +01:00
Salihu b814bd0f12 fix: checkbox filtering 2026-08-21 19:29:10 +01:00
Salihu b86abd3d40 Revert "fix: checkbox filtering"
This reverts commit 3b858746e3.
2026-08-21 19:23:48 +01:00
Salihu 3b858746e3 fix: checkbox filtering 2026-08-21 19:21:14 +01:00
Philipinho 66b424a3b8 fix: preserve hash in vimeo embed url 2026-08-20 22:25:45 +01:00
Philipinho ab43031375 fix race issue 2026-08-19 21:46:01 +01:00
Michael LohrandPhilipinho 8c2c49ea6d fix: handle empty password, missing port, and TLS in Redis URL parsing (#2021)
* fix: handle empty password, missing port, and TLS in Redis URL parsing
---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-08-19 21:05:44 +01:00
Philip Okugbe 8913d20aa0 chore: package updates (#2412) 2026-08-19 19:59:00 +01:00
16 changed files with 597 additions and 91 deletions
+13 -2
View File
@@ -22,7 +22,8 @@ import { TelemetryModule } from './integrations/telemetry/telemetry.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from './integrations/redis/redis-config.service';
import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
import KeyvRedis, { defaultReconnectStrategy } from '@keyv/redis';
import { parseRedisUrl } from './common/helpers';
import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
@@ -62,10 +63,20 @@ try {
isGlobal: true,
useFactory: async (environmentService: EnvironmentService) => {
const redisUrl = environmentService.getRedisUrl();
const { family, tls } = parseRedisUrl(redisUrl);
return {
ttl: 5 * 1000,
stores: [new KeyvRedis(redisUrl)],
stores: [
new KeyvRedis({
url: redisUrl,
socket: {
family,
reconnectStrategy: defaultReconnectStrategy,
...tls,
},
}),
],
};
},
inject: [EnvironmentService],
@@ -66,6 +66,7 @@ export class CollaborationGateway {
password: this.redisConfig.password,
db: this.redisConfig.db,
family: this.redisConfig.family,
tls: this.redisConfig.tls,
retryStrategy: createRetryStrategy(),
}),
serverId: `collab-${os?.hostname()}-${nanoid(10)}`,
+12 -4
View File
@@ -30,13 +30,14 @@ export type RedisConfig = {
db: number;
password?: string;
family?: number;
tls?: { rejectUnauthorized?: boolean };
};
export function parseRedisUrl(redisUrl: string): RedisConfig {
// format - redis[s]://[[username][:password]@][host][:port][/db-number][?family=4|6]
// format - redis[s]://[[username][:password]@][host][:port][/db-number][?family=4|6][&rejectUnauthorized=false]
const url = new URL(redisUrl);
const { hostname, port, password, pathname, searchParams } = url;
const portInt = parseInt(port, 10);
const { hostname, port, password, pathname, protocol, searchParams } = url;
const portInt = port ? parseInt(port, 10) : 6379;
let db: number = 0;
// extract db value if present
@@ -54,7 +55,14 @@ export function parseRedisUrl(redisUrl: string): RedisConfig {
family = parseInt(familyParam, 10);
}
return { host: hostname, port: portInt, password, db, family };
const tls =
protocol === 'rediss:'
? searchParams.get('rejectUnauthorized') === 'false'
? { rejectUnauthorized: false }
: {}
: undefined;
return { host: hostname, port: portInt, password: password || undefined, db, family, tls };
}
export function createRetryStrategy() {
@@ -339,15 +339,25 @@ export class SpaceMemberService {
return;
}
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId);
}
await executeTx(this.db, async (trx) => {
await trx
.selectFrom('spaces')
.select('id')
.where('id', '=', dto.spaceId)
.forUpdate()
.executeTakeFirst();
await this.spaceMemberRepo.updateSpaceMember(
{ role: dto.role },
spaceMember.id,
dto.spaceId,
);
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId, trx);
}
await this.spaceMemberRepo.updateSpaceMember(
{ role: dto.role },
spaceMember.id,
dto.spaceId,
trx,
);
});
this.auditService.log({
event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED,
@@ -368,10 +378,14 @@ export class SpaceMemberService {
});
}
async validateLastAdmin(spaceId: string): Promise<void> {
async validateLastAdmin(
spaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const spaceOwnerCount = await this.spaceMemberRepo.roleCountBySpaceId(
SpaceRole.ADMIN,
spaceId,
trx,
);
if (spaceOwnerCount === 1) {
throw new BadRequestException(
@@ -396,7 +396,10 @@ export class WorkspaceService {
}
}
if (updateWorkspaceDto.aiSearch) {
if (
updateWorkspaceDto.aiSearch &&
this.environmentService.getAiVectorDriver() !== 'turbopuffer'
) {
const tableExists = await isPageEmbeddingsTableExists(this.db);
if (!tableExists) {
throw new BadRequestException(
@@ -46,8 +46,10 @@ export class SpaceMemberRepo {
updatableSpaceMember: UpdatableSpaceMember,
spaceMemberId: string,
spaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await this.db
const db = dbOrTx(this.db, trx);
await db
.updateTable('spaceMembers')
.set(updatableSpaceMember)
.where('id', '=', spaceMemberId)
@@ -92,8 +94,13 @@ export class SpaceMemberRepo {
.execute();
}
async roleCountBySpaceId(role: string, spaceId: string): Promise<number> {
const { count } = await this.db
async roleCountBySpaceId(
role: string,
spaceId: string,
trx?: KyselyTransaction,
): Promise<number> {
const db = dbOrTx(this.db, trx);
const { count } = await db
.selectFrom('spaceMembers')
.select((eb) => eb.fn.count('role').as('count'))
.where('role', '=', role)
@@ -5,6 +5,7 @@ import {
import { Injectable, Logger } from '@nestjs/common';
import { EnvironmentService } from '../environment/environment.service';
import { Redis } from 'ioredis';
import { parseRedisUrl } from '../../common/helpers';
@Injectable()
export class RedisHealthIndicator {
@@ -19,8 +20,10 @@ export class RedisHealthIndicator {
const indicator = this.healthIndicatorService.check(key);
try {
const redis = new Redis(this.environmentService.getRedisUrl(), {
const redisUrl = this.environmentService.getRedisUrl();
const redis = new Redis(redisUrl, {
maxRetriesPerRequest: 15,
tls: parseRedisUrl(redisUrl).tls,
});
await redis.ping();
@@ -505,8 +505,19 @@ export class FileImportTaskService {
attachmentCandidates,
});
const processedHTML =
await this.importAttachmentService.processEmbeddedAttachments({
html: htmlContent,
pageId: page.id,
workspaceId: fileTask.workspaceId,
spaceId: fileTask.spaceId,
creatorId: fileTask.creatorId,
trx,
});
const { html, backlinks, pageIcon } = await formatImportHtml({
html: htmlContent,
html: processedHTML,
currentFilePath: page.filePath,
filePathToPageMetaMap: filePathToPageMetaMap,
creatorId: fileTask.creatorId,
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import * as path from 'path';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { cleanUrlString } from '../utils/file.utils';
import { StorageService } from '../../storage/storage.service';
import { createReadStream } from 'node:fs';
@@ -20,6 +20,11 @@ import pLimit from 'p-limit';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../queue/constants';
import { isBase64 } from "class-validator";
import { EnvironmentService } from '../../environment/environment.service';
import * as bytes from 'bytes';
import * as mimeTypes from 'mime-types';
import { dbOrTx } from '@docmost/db/utils';
interface AttachmentInfo {
href: string;
@@ -33,6 +38,34 @@ interface DrawioPair {
baseName: string;
}
interface AttachmentMeta {
pageId: string;
workspaceId: string;
spaceId: string;
creatorId: string;
trx: KyselyTransaction
}
interface UploadStats {
total: number;
completed: number;
failed: number;
failedFiles: string[];
}
const MIME_EXTENSION_OVERRIDES: Record<string, string> = {
'image/jpeg': '.jpg',
'audio/mpeg': '.mp3',
};
function resolveExtensionForMimeType(mimeType: string): string | null {
const override = MIME_EXTENSION_OVERRIDES[mimeType];
if (override) return override;
const ext = mimeTypes.extension(mimeType);
return ext ? `.${ext}` : null;
}
@Injectable()
export class ImportAttachmentService {
private readonly logger = new Logger(ImportAttachmentService.name);
@@ -42,10 +75,281 @@ export class ImportAttachmentService {
constructor(
private readonly storageService: StorageService,
private readonly environmentService: EnvironmentService,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
) {}
async processEmbeddedAttachments(
opts: AttachmentMeta & { html: string },
): Promise<string> {
const { html, ...rest } = opts;
const $ = load(html);
const limit = pLimit(this.CONCURRENT_UPLOADS);
const uploadStats: UploadStats = {
total: 0,
completed: 0,
failed: 0,
failedFiles: [],
};
type UploadedEmbed = {
apiFilePath: string;
attachmentId: string;
fileName: string;
} | null;
const processed = new Map<string, Promise<UploadedEmbed>>();
const resolveUri = (uri?: string): Promise<UploadedEmbed> => {
const normalized = uri?.trim();
if (!normalized || !normalized.toLowerCase().startsWith('data:')) {
return Promise.resolve(null);
}
const existing = processed.get(normalized);
if (existing) {
return existing;
}
const task = limit(async () => {
try {
const result = await this.uploadDataUri({
uri: normalized,
...rest,
});
if (result) {
uploadStats.completed++;
}
return result;
} catch (error) {
uploadStats.failed++;
uploadStats.failedFiles.push(normalized.slice(0, 80));
this.logger.error(
`Failed to process embedded attachment: ${
error instanceof Error ? error.message : String(error)
}`,
);
return null;
}
});
processed.set(normalized, task);
return task;
};
const attributeReplacements: Array<{
element: ReturnType<typeof $>;
attribute: string;
promise: Promise<UploadedEmbed>;
isImage?: boolean;
}> = [];
const posterReplacements: Array<{
element: ReturnType<typeof $>;
promise: Promise<UploadedEmbed>;
}> = [];
const embedReplacements: Array<{
element: ReturnType<typeof $>;
promise: Promise<UploadedEmbed>;
}> = [];
// img/video/audio/source: src attribute
for (const element of $(
'img[src], video[src], audio[src], source[src]',
).toArray()) {
const $element = $(element);
attributeReplacements.push({
element: $element,
attribute: 'src',
promise: resolveUri($element.attr('src')),
isImage: $element.is('img'),
});
}
// video poster (thumbnail image, often a separate data URI)
for (const element of $('video[poster]').toArray()) {
const $element = $(element);
posterReplacements.push({
element: $element,
promise: resolveUri($element.attr('poster')),
});
}
// the client does not currently support srcset.
// so we upload only the highest resolution, or the first image
for (const element of $('img[srcset]').toArray()) {
const $element = $(element);
const srcset = $element.attr('srcset');
if (!srcset) continue;
const candidates = srcset.split(/,\s+(?=\S)/);
let firstEmbeddedCandidate: string | undefined;
let bestWidthCandidate: any;
let bestDensityCandidate: any;
for (const candidate of candidates) {
if (!candidate) continue;
const trimmed = candidate.trim();
const match = trimmed.match(/^(\S+)(?:\s+(\d+(?:\.\d+)?)(w|x))?$/i);
if (!match) continue;
const [, uri, descriptorValue, descriptorType] = match;
if (!uri.toLowerCase().startsWith('data:')) {
continue;
}
const value = descriptorValue ? Number(descriptorValue) : undefined;
const type = descriptorType?.toLowerCase();
// Keep the first embedded image as the final fallback.
if (!firstEmbeddedCandidate) {
firstEmbeddedCandidate = uri;
if (type !== 'w' && type !== 'x') {
break;
}
}
if (
type === 'w' &&
value !== undefined &&
(!bestWidthCandidate || value > bestWidthCandidate.width)
) {
bestWidthCandidate = {
uri,
width: value,
};
}
if (
type === 'x' &&
value !== undefined &&
(!bestDensityCandidate || value > bestDensityCandidate.density)
) {
bestDensityCandidate = {
uri,
density: value,
};
}
}
const selectedUri =
bestWidthCandidate?.uri ??
bestDensityCandidate?.uri ??
firstEmbeddedCandidate;
attributeReplacements.push({
element: $element,
attribute: 'src',
promise: resolveUri(selectedUri),
isImage: true,
});
}
// the client represents embeds as iframes.
// so we convert embeds and objects as iframes
for (const element of $('object[data], embed[src]').toArray()) {
const $element = $(element);
const sourceAttribute = $element.is('object') ? 'data' : 'src';
embedReplacements.push({
element: $element,
promise: resolveUri($element.attr(sourceAttribute)),
});
}
for (const element of $('iframe[src]').toArray()) {
const $iframe = $(element);
attributeReplacements.push({
element: $iframe,
attribute: 'src',
promise: resolveUri($iframe.attr('src')),
});
}
// anchors
for (const element of $('a[href]').toArray()) {
const $element = $(element);
attributeReplacements.push({
element: $element,
attribute: 'href',
promise: resolveUri($element.attr('href')),
});
}
uploadStats.total = processed.size;
await Promise.all([
...attributeReplacements.map(
async ({ element, attribute, promise, isImage }) => {
const result = await promise;
if (!result) return;
element
.attr(attribute, result.apiFilePath)
.attr('data-attachment-id', result.attachmentId);
if (isImage) {
element.attr('data-align', element.attr('data-align') ?? 'center');
}
},
),
...posterReplacements.map(async ({ element, promise }) => {
const result = await promise;
if (!result) return;
element
.attr('poster', result.apiFilePath)
.attr('src', result.apiFilePath)
.attr('preload', 'metadata')
.attr('controls');
}),
...embedReplacements.map(async ({ element, promise }) => {
const result = await promise;
if (!result) return;
const $iframe = $('<iframe>')
.attr('src', result.apiFilePath)
.attr('data-attachment-id', result.attachmentId);
for (const attribute of ['width', 'height', 'title']) {
const value = element.attr(attribute);
if (value) {
$iframe.attr(attribute, value);
}
}
element.replaceWith($iframe);
}),
]);
if (uploadStats.total > 0) {
this.logger.debug(
`Embedded upload completed: ${uploadStats.completed}/${uploadStats.total} successful, ${uploadStats.failed} failed`,
);
if (uploadStats.failed > 0) {
this.logger.warn(
`Failed to upload ${uploadStats.failed} embedded attachments:`,
uploadStats.failedFiles,
);
}
}
return $.root().html() || '';
}
async processAttachments(opts: {
html: string;
pageRelativePath: string;
@@ -669,6 +973,131 @@ export class ImportAttachmentService {
return $.root().html() || '';
}
private async uploadStorageWithRetry(
storageFilePath: string,
content: Buffer,
): Promise<void> {
let lastError: unknown;
for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
try {
await this.storageService.upload(storageFilePath, content);
return;
} catch (error) {
lastError = error;
this.logger.warn(
`Storage upload attempt ${attempt}/${this.MAX_RETRIES} failed for ${storageFilePath}: ${
error instanceof Error ? error.message : String(error)
}`,
);
if (attempt < this.MAX_RETRIES) {
await new Promise((resolve) =>
setTimeout(resolve, this.RETRY_DELAY * attempt),
);
}
}
}
throw lastError;
}
private uploadDataUri = async ({
uri,
creatorId,
workspaceId,
pageId,
spaceId,
trx,
}: AttachmentMeta & { uri: string }): Promise<{
apiFilePath: string;
attachmentId: string;
fileName: string;
} | null> => {
if (!uri.toLowerCase().startsWith('data:')) {
return null;
}
const commaIndex = uri.indexOf(',');
if (commaIndex === -1) return null;
const metadata = uri.slice(5, commaIndex);
const encoded = uri.slice(commaIndex + 1).replace(/\s/g, '');
const metadataParts = metadata.split(';');
const mimeType = metadataParts.shift()?.toLowerCase();
if (!mimeType || !metadataParts.includes('base64')) {
return null;
}
const fileExt = resolveExtensionForMimeType(mimeType);
if (!fileExt) {
this.logger.warn(`Skipping unsupported embedded MIME type: ${mimeType}`);
return null;
}
if (!isBase64(encoded)) {
this.logger.warn(`Skipping malformed embedded ${mimeType} payload`);
return null;
}
const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit());
// before allocation a buffer to decode
// we want to reject obviously oversized files
const estimatedSize = Math.floor(encoded.length * 0.75);
if (estimatedSize > maxFileSize) {
this.logger.warn(
`Skipping embedded ${mimeType} payload exceeding size limit (${estimatedSize} bytes)`,
);
return null;
}
const buffer = Buffer.from(encoded, 'base64');
if (buffer.length === 0) return null;
if (buffer.length > maxFileSize) {
this.logger.warn(
`Skipping embedded ${mimeType} payload exceeding size limit (${buffer.length} bytes)`,
);
return null;
}
const attachmentId = v7();
const fileName = `${attachmentId}` + fileExt;
const storageFilePath = `${getAttachmentFolderPath(
AttachmentType.File,
workspaceId,
)}/${attachmentId}/${fileName}`;
const apiFilePath = `/api/files/${attachmentId}/${fileName}`;
await this.uploadStorageWithRetry(storageFilePath, buffer);
const db = dbOrTx(this.db, trx);
await db
.insertInto('attachments')
.values({
id: attachmentId,
filePath: storageFilePath,
fileName,
fileSize: buffer.length,
mimeType,
type: 'file',
fileExt: fileExt,
creatorId,
workspaceId,
pageId,
spaceId,
})
.execute();
return {
apiFilePath,
attachmentId,
fileName,
};
};
private analyzeAttachments(
attachments: AttachmentInfo[],
isConfluenceImport?: boolean,
@@ -31,6 +31,8 @@ import { QueueJob, QueueName } from '../../queue/constants';
import { ModuleRef } from '@nestjs/core';
import { load } from 'cheerio';
import { normalizeImportHtml } from '../utils/import-formatter';
import { ImportAttachmentService } from './import-attachment.service';
import { executeTx } from "@docmost/db/utils";
@Injectable()
export class ImportService {
@@ -43,6 +45,7 @@ export class ImportService {
@InjectQueue(QueueName.FILE_TASK_QUEUE)
private readonly fileTaskQueue: Queue,
private moduleRef: ModuleRef,
private readonly importAttachmentService: ImportAttachmentService,
) {}
async importPage(
@@ -62,79 +65,86 @@ export class ImportService {
let prosemirrorState = null;
let createdPage = null;
// For DOCX, we need the page ID upfront so images can reference it
const pageId =
fileExtension === '.docx' || fileExtension === '.pdf'
? uuid7()
: undefined;
// Generate the page ID upfront so imported attachments can reference it.
const pageId = uuid7();
try {
if (fileExtension.endsWith('.md')) {
prosemirrorState = await this.processMarkdown(fileContent);
} else if (fileExtension.endsWith('.html')) {
prosemirrorState = await this.processHTML(fileContent);
} else if (fileExtension.endsWith('.docx')) {
prosemirrorState = await this.processDocx(
fileBuffer,
workspaceId,
spaceId,
pageId,
userId,
createdPage = await executeTx(this.db, async (trx) => {
if (fileExtension.endsWith('.md') || fileExtension.endsWith('.html')) {
const rawHtml = fileExtension.endsWith('.md')
? await markdownToHtml(fileContent)
: fileContent;
const processedHtml =
await this.importAttachmentService.processEmbeddedAttachments({
html: rawHtml,
pageId,
workspaceId,
spaceId,
creatorId: userId,
trx,
});
prosemirrorState = await this.processHTML(processedHtml);
} else if (fileExtension.endsWith('.docx')) {
prosemirrorState = await this.processDocx(
fileBuffer,
workspaceId,
spaceId,
pageId,
userId,
);
} else if (fileExtension.endsWith('.pdf')) {
prosemirrorState = await this.processPdf(
fileBuffer,
workspaceId,
spaceId,
pageId,
userId,
);
}
if (!prosemirrorState) {
const message = 'Failed to create ProseMirror state';
this.logger.error(message);
throw new BadRequestException(message);
}
const { title, prosemirrorJson } = this.extractTitleAndRemoveHeading(
prosemirrorState,
{ anyHeadingLevel: true },
);
} else if (fileExtension.endsWith('.pdf')) {
prosemirrorState = await this.processPdf(
fileBuffer,
workspaceId,
spaceId,
pageId,
userId,
);
}
} catch (err) {
const message = 'Error processing file content';
this.logger.error(message, err);
throw new BadRequestException(message);
}
if (!prosemirrorState) {
const message = 'Failed to create ProseMirror state';
this.logger.error(message);
throw new BadRequestException(message);
}
const { title, prosemirrorJson } = this.extractTitleAndRemoveHeading(
prosemirrorState,
{ anyHeadingLevel: true },
);
const pageTitle = title || fileName;
if (prosemirrorJson) {
try {
const pageTitle = title || fileName;
const pagePosition = await this.getNewPagePosition(spaceId);
createdPage = await this.pageRepo.insertPage({
...(pageId ? { id: pageId } : {}),
slugId: generateSlugId(),
title: pageTitle,
content: prosemirrorJson,
textContent: jsonToText(prosemirrorJson),
ydoc: await this.createYdoc(prosemirrorJson),
position: pagePosition,
spaceId: spaceId,
creatorId: userId,
workspaceId: workspaceId,
lastUpdatedById: userId,
});
const page = await this.pageRepo.insertPage(
{
id: pageId,
slugId: generateSlugId(),
title: pageTitle,
content: prosemirrorJson,
textContent: jsonToText(prosemirrorJson),
ydoc: await this.createYdoc(prosemirrorJson),
position: pagePosition,
spaceId: spaceId,
creatorId: userId,
workspaceId: workspaceId,
lastUpdatedById: userId,
},
trx,
);
this.logger.debug(
`Successfully imported "${title}${fileExtension}. ID: ${createdPage.id} - SlugId: ${createdPage.slugId}"`,
`Successfully imported "${title}${fileExtension}. ID: ${page.id} - SlugId: ${page.slugId}"`,
);
} catch (err) {
const message = 'Failed to create imported page';
this.logger.error(message, err);
throw new BadRequestException(message);
}
return page;
});
} catch (err) {
const message = 'Failed to create imported page';
this.logger.error(message, err);
throw new BadRequestException(message);
}
return createdPage;
@@ -18,6 +18,7 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
password: redisConfig.password,
db: redisConfig.db,
family: redisConfig.family,
tls: redisConfig.tls,
retryStrategy: createRetryStrategy(),
},
defaultJobOptions: {
@@ -19,6 +19,7 @@ export class RedisConfigService implements RedisOptionsFactory {
password: redisConfig.password,
db: redisConfig.db,
family: redisConfig.family,
tls: redisConfig.tls,
retryStrategy: createRetryStrategy(),
},
};
@@ -3,7 +3,7 @@ import { ThrottlerModule } from '@nestjs/throttler';
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
import { EnvironmentService } from '../environment/environment.service';
import { EnvironmentModule } from '../environment/environment.module';
import { parseRedisUrl } from '../../common/helpers';
import { createRetryStrategy, parseRedisUrl } from '../../common/helpers';
import { AUTH_THROTTLER, AI_CHAT_THROTTLER } from './throttler-names';
import Redis from 'ioredis';
@@ -27,6 +27,8 @@ import Redis from 'ioredis';
password: redisConfig.password,
db: redisConfig.db,
family: redisConfig.family,
tls: redisConfig.tls,
retryStrategy: createRetryStrategy(),
keyPrefix: 'throttle:',
}),
),
@@ -17,6 +17,7 @@ export class WsRedisIoAdapter extends IoAdapter {
const options: RedisOptions = {
family: this.redisConfig.family,
tls: this.redisConfig.tls,
retryStrategy: createRetryStrategy(),
};
@@ -73,9 +73,13 @@ export const embedProviders: IEmbedProvider[] = [
id: "vimeo",
name: "Vimeo",
regex:
/^(https:)?\/\/(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|album\/(\d+)\/video\/|video\/|)(\d+)/,
getEmbedUrl: (match) => {
return `https://player.vimeo.com/video/${match[4]}`;
/^(https:)?\/\/(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|album\/(\d+)\/video\/|video\/|)(\d+)(?:\/([\da-zA-Z]+))?/,
getEmbedUrl: (match, url: string) => {
// preserve ?h= hash for unlisted videos
const hash =
match[5] ?? new URL(url, "https://vimeo.com").searchParams.get("h");
const base = `https://player.vimeo.com/video/${match[4]}`;
return hash ? `${base}?h=${hash}` : base;
},
},
{