Compare commits

..
Author SHA1 Message Date
Salihu 89c22944ee revert uninteded changes 2026-08-23 21:15:41 +01:00
Salihu b4da604512 lightbox media display init 2026-08-23 20:47:33 +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 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
20 changed files with 306 additions and 24 deletions
+1
View File
@@ -64,6 +64,7 @@
"react-router-dom": "7.18.2",
"semver": "7.7.4",
"socket.io-client": "4.8.3",
"yet-another-react-lightbox": "^3.32.2",
"zod": "4.3.6"
},
"devDependencies": {
@@ -294,6 +294,9 @@
"Export space": "Export space",
"Export {{type}}": "Export {{type}}",
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
"Media": "Media",
"Open image": "Open image",
"Open video": "Open video",
"Align left": "Align left",
"Align right": "Align right",
"Align center": "Align center",
@@ -16,6 +16,14 @@ export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false);
export type LightboxRequest = {
src: string;
type: "image" | "video";
} | null;
const initialLightboxRequest: LightboxRequest = null;
export const lightboxRequestAtom = atom(initialLightboxRequest);
// Current page's edit mode — initialized from the user's saved preference on
// first load, can be toggled locally without persisting to the server.
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
@@ -0,0 +1,129 @@
import type { Editor } from "@tiptap/react";
import Lightbox, { type Slide } from "yet-another-react-lightbox";
import { getFileUrl } from "@/lib/config.ts";
import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/captions.css";
import Captions from "yet-another-react-lightbox/plugins/captions";
import Download from "yet-another-react-lightbox/plugins/download";
import Fullscreen from "yet-another-react-lightbox/plugins/fullscreen";
import Video from "yet-another-react-lightbox/plugins/video";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import { useMemo } from "react";
import i18n from "@/i18n.ts";
import { useTranslation } from "react-i18next";
type LightboxViewProps = {
editor: Editor;
open: boolean;
src: string;
type: "image" | "video";
onClose: () => void;
};
function getVideoMimeType(src: string) {
const extension = src.split(/[?#]/, 1)[0].split(".").pop()?.toLowerCase();
switch (extension) {
case "webm":
return "video/webm";
case "ogv":
return "video/ogg";
case "mov":
return "video/quicktime";
case "m4v":
return "video/x-m4v";
default:
return "video/mp4";
}
}
function getFilename(src: string) {
const filename = src.split(/[?#]/, 1)[0].split("/").pop();
if (!filename) return i18n.t("Media");
try {
return decodeURIComponent(filename);
} catch {
return filename;
}
}
function getMedia(rawSrc: string, type?: string, alt?: string): Slide {
const src = getFileUrl(rawSrc);
const filename = getFilename(rawSrc);
const caption = alt || filename;
if (type === "video") {
return {
type: "video",
sources: [{ src, type: getVideoMimeType(rawSrc) }],
title: caption,
download: { url: src, filename },
};
} else {
return {
type: "image",
src,
alt: alt || undefined,
title: caption,
download: { url: src, filename },
};
}
}
function getPageMedia(editor: Editor): Slide[] {
const media: Slide[] = [];
editor.state.doc.descendants((node) => {
if (node.type.name !== "image" && node.type.name !== "video") return;
const rawSrc = typeof node.attrs.src === "string" ? node.attrs.src : "";
if (!rawSrc) return;
media.push(getMedia(rawSrc, node.type.name, node.attrs.alt));
});
return media;
}
export default function LightboxView({
editor,
open,
src,
type,
onClose,
}: LightboxViewProps) {
const { i18n: i18nInstance } = useTranslation();
const slides = useMemo(
() => getPageMedia(editor),
[editor, open, i18nInstance.language]
);
const index = useMemo(() => {
const idx = slides.findIndex((slide) =>
type === "video"
? "sources" in slide && slide.sources.some((s) => s.src === src)
: "src" in slide && slide.src === src
);
return idx >= 0 ? idx : 0;
}, [slides, src, type]);
return (
<Lightbox
open={open}
close={onClose}
index={index >= 0 ? index : 0}
slides={slides}
plugins={[Captions, Download, Fullscreen, Video, Zoom]}
captions={{ descriptionTextAlign: "center" }}
video={{ controls: true, playsInline: true }}
zoom={{
scrollToZoom: true,
maxZoomPixelRatio: 4,
maxZoom: 4,
supports: ["video"],
}}
/>
);
}
@@ -1,6 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback, useRef } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
@@ -14,6 +15,7 @@ import {
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconDownload,
IconMaximize,
IconRefresh,
IconTrash,
} from "@tabler/icons-react";
@@ -21,11 +23,13 @@ import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms";
import classes from "../common/toolbar-menu.module.css";
export function ImageMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const editorState = useEditorState({
editor,
@@ -165,6 +169,25 @@ export function ImageMenu({ editor }: EditorMenuProps) {
altTextPanel
) : (
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Open image")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "image",
})
}
size="lg"
aria-label={t("Open image")}
variant="subtle"
>
<IconMaximize size={18} />
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
<ActionIcon
onClick={alignImageLeft}
@@ -1,6 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
@@ -14,15 +15,18 @@ import {
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconDownload,
IconMaximize,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms";
import classes from "../common/toolbar-menu.module.css";
export function VideoMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const editorState = useEditorState({
editor,
@@ -141,6 +145,25 @@ export function VideoMenu({ editor }: EditorMenuProps) {
altTextPanel
) : (
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Open video")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "video",
})
}
size="lg"
aria-label={t("Open video")}
variant="subtle"
>
<IconMaximize size={18} />
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
<ActionIcon
onClick={alignLeft}
@@ -33,6 +33,7 @@ import { useAtom, useAtomValue } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
currentPageEditModeAtom,
lightboxRequestAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
yjsSyncedAtom,
@@ -53,6 +54,7 @@ import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx";
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
import LightboxView from "@/features/editor/components/common/lightbox-view";
import {
handleFileDrop,
handlePaste,
@@ -184,6 +186,7 @@ function CollabPageEditor({
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
@@ -459,6 +462,15 @@ function CollabPageEditor({
{editor && !editorIsEditable && (editable || canComment) && (
<ReadonlyBubbleMenu editor={editor} />
)}
{editor && (
<LightboxView
editor={editor}
open={!!lightboxRequest}
src={lightboxRequest?.src ?? ""}
type={lightboxRequest?.type ?? "image"}
onClose={() => setLightboxRequest(null)}
/>
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
{showReadOnlyCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} readOnly />
+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();
@@ -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;
},
},
{
+27
View File
@@ -397,6 +397,9 @@ importers:
socket.io-client:
specifier: 4.8.3
version: 4.8.3(supports-color@10.2.2)
yet-another-react-lightbox:
specifier: ^3.32.2
version: 3.32.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
zod:
specifier: 4.3.6
version: 4.3.6
@@ -4991,6 +4994,7 @@ packages:
'@xmldom/xmldom@0.8.13':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
'@xtuc/ieee754@1.2.0':
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@@ -6319,6 +6323,7 @@ packages:
eslint@9.28.0:
resolution: {integrity: sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
peerDependencies:
jiti: '*'
@@ -10033,6 +10038,20 @@ packages:
resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==}
engines: {node: '>=12'}
yet-another-react-lightbox@3.32.2:
resolution: {integrity: sha512-F4HtHQfUNpvkj+AmECgWM4XRdCqMY5gXpKgOUx39+T+FyxLe8II4SK/pwMyYj2X54KH9lFSQeHYY1/GfYf3SdA==}
engines: {node: '>=14'}
peerDependencies:
'@types/react': ^16 || ^17 || ^18 || ^19
'@types/react-dom': ^16 || ^17 || ^18 || ^19
react: ^16.8.0 || ^17 || ^18 || ^19
react-dom: ^16.8.0 || ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
yjs@13.6.30:
resolution: {integrity: sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
@@ -20744,6 +20763,14 @@ snapshots:
dependencies:
pend: 1.2.0
yet-another-react-lightbox@3.32.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
yjs@13.6.30:
dependencies:
lib0: 0.2.117