From 3d05d22e90405f9ab455d6c0403a68bc50c540c6 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Tue, 2 Dec 2025 12:58:01 -0800 Subject: [PATCH] wip: shared links ttl prototype --- .env.example | 2 + api/server/routes/share.js | 9 +- .../ConvoOptions/SharedLinkButton.tsx | 41 +++++++-- .../Nav/SettingsTabs/Data/SharedLinks.tsx | 22 +++++ client/src/data-provider/mutations.ts | 16 +++- client/src/locales/en/translation.json | 8 ++ client/src/utils/shareExpiry.ts | 32 +++++++ packages/data-provider/src/data-service.ts | 6 +- packages/data-schemas/src/methods/share.ts | 15 ++++ packages/data-schemas/src/schema/share.ts | 6 ++ packages/data-schemas/src/types/share.ts | 3 + .../data-schemas/src/utils/shareExpiration.ts | 87 +++++++++++++++++++ 12 files changed, 234 insertions(+), 13 deletions(-) create mode 100644 client/src/utils/shareExpiry.ts create mode 100644 packages/data-schemas/src/utils/shareExpiration.ts diff --git a/.env.example b/.env.example index 90995be72f..5f3b857377 100644 --- a/.env.example +++ b/.env.example @@ -623,6 +623,8 @@ AZURE_CONTAINER_NAME=files ALLOW_SHARED_LINKS=true ALLOW_SHARED_LINKS_PUBLIC=true +# Default expiration time for shared links in hours (default: 0 = never expires) +# SHARED_LINK_DEFAULT_TTL_HOURS=0 #==============================# # Static File Cache Control # diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 6400b8b637..7f72f60e93 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -99,8 +99,13 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => { router.post('/:conversationId', requireJwtAuth, async (req, res) => { try { - const { targetMessageId } = req.body; - const created = await createSharedLink(req.user.id, req.params.conversationId, targetMessageId); + const { targetMessageId, expirationHours } = req.body; + const created = await createSharedLink( + req.user.id, + req.params.conversationId, + targetMessageId, + expirationHours, + ); if (created) { res.status(200).json(created); } else { diff --git a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx index e5d1dbfb20..ad475651cb 100644 --- a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx @@ -6,6 +6,7 @@ import { Spinner, TooltipAnchor, Label, + Dropdown, OGDialogTemplate, useToastContext, } from '@librechat/client'; @@ -17,6 +18,7 @@ import { } from '~/data-provider'; import { NotificationSeverity } from '~/common'; import { useLocalize } from '~/hooks'; +import { SHARE_EXPIRY } from '~/utils/shareExpiry'; export default function SharedLinkButton({ share, @@ -38,8 +40,12 @@ export default function SharedLinkButton({ const localize = useLocalize(); const { showToast } = useToastContext(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [expiryLabel, setExpiryLabel] = useState(SHARE_EXPIRY.THIRTY_DAYS.label); const shareId = share?.shareId ?? ''; + const expirationOptions = Object.values(SHARE_EXPIRY); + const localizedOptions = expirationOptions.map((option) => localize(option.label)); + const { mutateAsync: mutate, isLoading: isCreateLoading } = useCreateSharedLinkMutation({ onError: () => { showToast({ @@ -88,7 +94,9 @@ export default function SharedLinkButton({ }; const createShareLink = async () => { - const share = await mutate({ conversationId, targetMessageId }); + const selectedOption = expirationOptions.find((option) => option.label === expiryLabel); + const expirationHours = selectedOption ? selectedOption.hours : undefined; + const share = await mutate({ conversationId, targetMessageId, expirationHours }); const newLink = generateShareLink(share.shareId); setSharedLink(newLink); }; @@ -117,12 +125,33 @@ export default function SharedLinkButton({ return ( <> -
+
{!shareId && ( - + <> +
+ + { + const option = expirationOptions.find((opt) => localize(opt.label) === value); + if (option) { + setExpiryLabel(option.label); + } + }} + options={localizedOptions} + sizeClasses="flex-1" + portal={false} + /> +
+ + )} {shareId && (
diff --git a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx index bcc6a4af9c..78152551e6 100644 --- a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx @@ -232,6 +232,28 @@ export default function SharedLinks() { mobileSize: '20%', }, }, + { + accessorKey: 'expiresAt', + header: () => , + cell: ({ row }) => { + const expiresAt = row.original.expiresAt; + if (!expiresAt) { + return {localize('com_ui_never')}; + } + const expiryDate = new Date(expiresAt); + const isExpired = expiryDate < new Date(); + return ( + + {isExpired ? `${localize('com_ui_expired')} ` : ''} + {formatDate(expiresAt.toString(), isSmallScreen)} + + ); + }, + meta: { + size: '10%', + mobileSize: '20%', + }, + }, { accessorKey: 'actions', header: () => ( diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index 7abea71187..fb5b55d1c8 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -170,24 +170,32 @@ export const useArchiveConvoMutation = ( export const useCreateSharedLinkMutation = ( options?: t.MutationOptions< t.TCreateShareLinkRequest, - { conversationId: string; targetMessageId?: string } + { conversationId: string; targetMessageId?: string; expirationHours?: number } >, ): UseMutationResult< t.TSharedLinkResponse, unknown, - { conversationId: string; targetMessageId?: string }, + { conversationId: string; targetMessageId?: string; expirationHours?: number }, unknown > => { const queryClient = useQueryClient(); const { onSuccess, ..._options } = options || {}; return useMutation( - ({ conversationId, targetMessageId }: { conversationId: string; targetMessageId?: string }) => { + ({ + conversationId, + targetMessageId, + expirationHours, + }: { + conversationId: string; + targetMessageId?: string; + expirationHours?: number; + }) => { if (!conversationId) { throw new Error('Conversation ID is required'); } - return dataService.createSharedLink(conversationId, targetMessageId); + return dataService.createSharedLink(conversationId, targetMessageId, expirationHours); }, { onSuccess: (_data: t.TSharedLinkResponse, vars, context) => { diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index a82e931072..e6fb93533c 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -900,6 +900,13 @@ "com_ui_everyone_permission_level": "Everyone's permission level", "com_ui_examples": "Examples", "com_ui_expand_chat": "Expand Chat", + "com_ui_expired": "Expired", + "com_ui_expires": "Expires", + "com_ui_expiry_hour": "Hour", + "com_ui_expiry_12_hours": "12 Hours", + "com_ui_expiry_day": "Day", + "com_ui_expiry_week": "Week", + "com_ui_expiry_month": "Month", "com_ui_export_convo_modal": "Export Conversation Modal", "com_ui_feedback_more": "More...", "com_ui_feedback_more_information": "Provide additional feedback", @@ -1049,6 +1056,7 @@ "com_ui_more_info": "More info", "com_ui_my_prompts": "My Prompts", "com_ui_name": "Name", + "com_ui_never": "Never", "com_ui_new": "New", "com_ui_new_chat": "New chat", "com_ui_new_conversation_title": "New Conversation Title", diff --git a/client/src/utils/shareExpiry.ts b/client/src/utils/shareExpiry.ts new file mode 100644 index 0000000000..4561d285b2 --- /dev/null +++ b/client/src/utils/shareExpiry.ts @@ -0,0 +1,32 @@ +export const SHARE_EXPIRY = { + ONE_HOUR: { + label: 'com_ui_expiry_hour', + value: 60 * 60 * 1000, // 1 hour in ms + hours: 1, + }, + TWELVE_HOURS: { + label: 'com_ui_expiry_12_hours', + value: 12 * 60 * 60 * 1000, + hours: 12, + }, + ONE_DAY: { + label: 'com_ui_expiry_day', + value: 24 * 60 * 60 * 1000, + hours: 24, + }, + SEVEN_DAYS: { + label: 'com_ui_expiry_week', + value: 7 * 24 * 60 * 60 * 1000, + hours: 168, + }, + THIRTY_DAYS: { + label: 'com_ui_expiry_month', + value: 30 * 24 * 60 * 60 * 1000, + hours: 720, + }, + NEVER: { + label: 'com_ui_never', + value: 0, + hours: 0, + }, +} as const; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index c7d1a1c052..e65146b002 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -45,8 +45,12 @@ export function getSharedLink(conversationId: string): Promise { - return request.post(endpoints.createSharedLink(conversationId), { targetMessageId }); + return request.post(endpoints.createSharedLink(conversationId), { + targetMessageId, + expirationHours, + }); } export function updateSharedLink(shareId: string): Promise { diff --git a/packages/data-schemas/src/methods/share.ts b/packages/data-schemas/src/methods/share.ts index 2a0d2bc3bd..5ba00e0f8a 100644 --- a/packages/data-schemas/src/methods/share.ts +++ b/packages/data-schemas/src/methods/share.ts @@ -4,6 +4,7 @@ import type { FilterQuery, Model } from 'mongoose'; import type { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili'; import type * as t from '~/types'; import logger from '~/config/winston'; +import { createShareExpirationDate, isShareExpired } from '~/utils/shareExpiration'; class ShareServiceError extends Error { code: string; @@ -173,6 +174,15 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { return null; } + // Check if share has expired + if (isShareExpired(share.expiresAt)) { + logger.warn('[getSharedMessages] Share has expired', { + shareId, + expiresAt: share.expiresAt, + }); + throw new ShareServiceError('This shared link has expired', 'SHARE_EXPIRED'); + } + /** Filtered messages based on targetMessageId if present (branch-specific sharing) */ let messagesToShare: t.IMessage[] = share.messages; if (share.targetMessageId) { @@ -184,6 +194,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { shareId: share.shareId || shareId, title: share.title, isPublic: share.isPublic, + expiresAt: share.expiresAt, createdAt: share.createdAt, updatedAt: share.updatedAt, conversationId: newConvoId, @@ -275,6 +286,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { shareId: link.shareId || '', title: link?.title || 'Untitled', isPublic: link.isPublic, + expiresAt: link.expiresAt, createdAt: link.createdAt || new Date(), conversationId: link.conversationId, })), @@ -345,6 +357,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { user: string, conversationId: string, targetMessageId?: string, + expirationHours?: number, ): Promise { if (!user || !conversationId) { throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); @@ -401,6 +414,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { const title = conversation.title || 'Untitled'; const shareId = nanoid(); + const expiresAt = createShareExpirationDate(expirationHours); await SharedLink.create({ shareId, conversationId, @@ -408,6 +422,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { title, user, ...(targetMessageId && { targetMessageId }), + ...(expiresAt && { expiresAt }), }); return { shareId, conversationId }; diff --git a/packages/data-schemas/src/schema/share.ts b/packages/data-schemas/src/schema/share.ts index 987dd10fc2..3534d0ba77 100644 --- a/packages/data-schemas/src/schema/share.ts +++ b/packages/data-schemas/src/schema/share.ts @@ -8,6 +8,7 @@ export interface ISharedLink extends Document { shareId?: string; targetMessageId?: string; isPublic: boolean; + expiresAt?: Date; createdAt?: Date; updatedAt?: Date; } @@ -40,10 +41,15 @@ const shareSchema: Schema = new Schema( type: Boolean, default: true, }, + expiresAt: { + type: Date, + required: false, + }, }, { timestamps: true }, ); shareSchema.index({ conversationId: 1, user: 1, targetMessageId: 1 }); +shareSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); export default shareSchema; diff --git a/packages/data-schemas/src/types/share.ts b/packages/data-schemas/src/types/share.ts index 8b54990cf4..4facaf0830 100644 --- a/packages/data-schemas/src/types/share.ts +++ b/packages/data-schemas/src/types/share.ts @@ -10,6 +10,7 @@ export interface ISharedLink { shareId?: string; targetMessageId?: string; isPublic: boolean; + expiresAt?: Date; createdAt?: Date; updatedAt?: Date; } @@ -23,6 +24,7 @@ export interface SharedLinksResult { shareId: string; title: string; isPublic: boolean; + expiresAt?: Date; createdAt: Date; conversationId: string; }>; @@ -36,6 +38,7 @@ export interface SharedMessagesResult { shareId: string; title?: string; isPublic: boolean; + expiresAt?: Date; createdAt?: Date; updatedAt?: Date; } diff --git a/packages/data-schemas/src/utils/shareExpiration.ts b/packages/data-schemas/src/utils/shareExpiration.ts new file mode 100644 index 0000000000..ef78e9cc47 --- /dev/null +++ b/packages/data-schemas/src/utils/shareExpiration.ts @@ -0,0 +1,87 @@ +import logger from '~/config/winston'; + +/** + * Default expiration period for shared links in hours + */ +export const DEFAULT_SHARE_EXPIRATION_HOURS = 0; // never expires + +/** + * Minimum allowed expiration period in hours + */ +export const MIN_SHARE_EXPIRATION_HOURS = 1; + +/** + * Maximum allowed expiration period in hours (1 year = 8760 hours) + */ +export const MAX_SHARE_EXPIRATION_HOURS = 8760; + +/** + * Gets the shared link expiration period from environment variables + * @returns The expiration period in hours, or 0 for no expiration + */ +export function getSharedLinkExpirationHours(): number { + const envValue = process.env.SHARED_LINK_DEFAULT_TTL_HOURS; + + if (!envValue) { + return DEFAULT_SHARE_EXPIRATION_HOURS; + } + + const parsed = parseInt(envValue, 10); + + if (isNaN(parsed)) { + logger.warn( + `[shareExpiration] Invalid SHARED_LINK_DEFAULT_TTL_HOURS: ${envValue}, using default: ${DEFAULT_SHARE_EXPIRATION_HOURS}`, + ); + return DEFAULT_SHARE_EXPIRATION_HOURS; + } + + // 0 means no expiration + if (parsed === 0) { + return 0; + } + + // Clamp to min/max + if (parsed < MIN_SHARE_EXPIRATION_HOURS) { + logger.warn( + `[shareExpiration] SHARED_LINK_DEFAULT_TTL_HOURS too low: ${parsed}, using minimum: ${MIN_SHARE_EXPIRATION_HOURS}`, + ); + return MIN_SHARE_EXPIRATION_HOURS; + } + + if (parsed > MAX_SHARE_EXPIRATION_HOURS) { + logger.warn( + `[shareExpiration] SHARED_LINK_DEFAULT_TTL_HOURS too high: ${parsed}, using maximum: ${MAX_SHARE_EXPIRATION_HOURS}`, + ); + return MAX_SHARE_EXPIRATION_HOURS; + } + + return parsed; +} + +/** + * Creates an expiration date for a shared link + * @param hours - Optional hours override. If not provided, uses environment config. 0 = no expiration. + * @returns The expiration date, or undefined if no expiration should be set + */ +export function createShareExpirationDate(hours?: number): Date | undefined { + const expirationHours = hours !== undefined ? hours : getSharedLinkExpirationHours(); + + // 0 means no expiration + if (expirationHours === 0) { + return undefined; + } + + return new Date(Date.now() + expirationHours * 60 * 60 * 1000); +} + +/** + * Checks if a shared link has expired + * @param expiresAt - The expiration date + * @returns True if expired, false otherwise + */ +export function isShareExpired(expiresAt?: Date | null): boolean { + if (!expiresAt) { + return false; + } + return new Date() > new Date(expiresAt); +}