wip: shared links ttl prototype

This commit is contained in:
Dustin Healy 2025-12-02 12:58:01 -08:00
parent 8bdc808074
commit 3d05d22e90
12 changed files with 234 additions and 13 deletions

View file

@ -623,6 +623,8 @@ AZURE_CONTAINER_NAME=files
ALLOW_SHARED_LINKS=true ALLOW_SHARED_LINKS=true
ALLOW_SHARED_LINKS_PUBLIC=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 # # Static File Cache Control #

View file

@ -99,8 +99,13 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
router.post('/:conversationId', requireJwtAuth, async (req, res) => { router.post('/:conversationId', requireJwtAuth, async (req, res) => {
try { try {
const { targetMessageId } = req.body; const { targetMessageId, expirationHours } = req.body;
const created = await createSharedLink(req.user.id, req.params.conversationId, targetMessageId); const created = await createSharedLink(
req.user.id,
req.params.conversationId,
targetMessageId,
expirationHours,
);
if (created) { if (created) {
res.status(200).json(created); res.status(200).json(created);
} else { } else {

View file

@ -6,6 +6,7 @@ import {
Spinner, Spinner,
TooltipAnchor, TooltipAnchor,
Label, Label,
Dropdown,
OGDialogTemplate, OGDialogTemplate,
useToastContext, useToastContext,
} from '@librechat/client'; } from '@librechat/client';
@ -17,6 +18,7 @@ import {
} from '~/data-provider'; } from '~/data-provider';
import { NotificationSeverity } from '~/common'; import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { SHARE_EXPIRY } from '~/utils/shareExpiry';
export default function SharedLinkButton({ export default function SharedLinkButton({
share, share,
@ -38,8 +40,12 @@ export default function SharedLinkButton({
const localize = useLocalize(); const localize = useLocalize();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [expiryLabel, setExpiryLabel] = useState(SHARE_EXPIRY.THIRTY_DAYS.label);
const shareId = share?.shareId ?? ''; const shareId = share?.shareId ?? '';
const expirationOptions = Object.values(SHARE_EXPIRY);
const localizedOptions = expirationOptions.map((option) => localize(option.label));
const { mutateAsync: mutate, isLoading: isCreateLoading } = useCreateSharedLinkMutation({ const { mutateAsync: mutate, isLoading: isCreateLoading } = useCreateSharedLinkMutation({
onError: () => { onError: () => {
showToast({ showToast({
@ -88,7 +94,9 @@ export default function SharedLinkButton({
}; };
const createShareLink = async () => { 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); const newLink = generateShareLink(share.shareId);
setSharedLink(newLink); setSharedLink(newLink);
}; };
@ -117,12 +125,33 @@ export default function SharedLinkButton({
return ( return (
<> <>
<div className="flex gap-2"> <div className="flex flex-col gap-2">
{!shareId && ( {!shareId && (
<Button disabled={isCreateLoading} variant="submit" onClick={createShareLink}> <>
{!isCreateLoading && localize('com_ui_create_link')} <div className="flex items-center gap-2">
{isCreateLoading && <Spinner className="size-4" />} <Label htmlFor="expiry-select" className="text-sm">
</Button> {localize('com_ui_expires')}:
</Label>
<Dropdown
id="expiry-select"
label=""
value={localize(expiryLabel)}
onChange={(value) => {
const option = expirationOptions.find((opt) => localize(opt.label) === value);
if (option) {
setExpiryLabel(option.label);
}
}}
options={localizedOptions}
sizeClasses="flex-1"
portal={false}
/>
</div>
<Button disabled={isCreateLoading} variant="submit" onClick={createShareLink}>
{!isCreateLoading && localize('com_ui_create_link')}
{isCreateLoading && <Spinner className="size-4" />}
</Button>
</>
)} )}
{shareId && ( {shareId && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -232,6 +232,28 @@ export default function SharedLinks() {
mobileSize: '20%', mobileSize: '20%',
}, },
}, },
{
accessorKey: 'expiresAt',
header: () => <Label className="px-2 text-xs sm:text-sm">{localize('com_ui_expires')}</Label>,
cell: ({ row }) => {
const expiresAt = row.original.expiresAt;
if (!expiresAt) {
return <span className="text-xs text-text-secondary sm:text-sm">{localize('com_ui_never')}</span>;
}
const expiryDate = new Date(expiresAt);
const isExpired = expiryDate < new Date();
return (
<span className={`text-xs sm:text-sm ${isExpired ? 'text-red-500' : 'text-text-secondary'}`}>
{isExpired ? `${localize('com_ui_expired')} ` : ''}
{formatDate(expiresAt.toString(), isSmallScreen)}
</span>
);
},
meta: {
size: '10%',
mobileSize: '20%',
},
},
{ {
accessorKey: 'actions', accessorKey: 'actions',
header: () => ( header: () => (

View file

@ -170,24 +170,32 @@ export const useArchiveConvoMutation = (
export const useCreateSharedLinkMutation = ( export const useCreateSharedLinkMutation = (
options?: t.MutationOptions< options?: t.MutationOptions<
t.TCreateShareLinkRequest, t.TCreateShareLinkRequest,
{ conversationId: string; targetMessageId?: string } { conversationId: string; targetMessageId?: string; expirationHours?: number }
>, >,
): UseMutationResult< ): UseMutationResult<
t.TSharedLinkResponse, t.TSharedLinkResponse,
unknown, unknown,
{ conversationId: string; targetMessageId?: string }, { conversationId: string; targetMessageId?: string; expirationHours?: number },
unknown unknown
> => { > => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { onSuccess, ..._options } = options || {}; const { onSuccess, ..._options } = options || {};
return useMutation( return useMutation(
({ conversationId, targetMessageId }: { conversationId: string; targetMessageId?: string }) => { ({
conversationId,
targetMessageId,
expirationHours,
}: {
conversationId: string;
targetMessageId?: string;
expirationHours?: number;
}) => {
if (!conversationId) { if (!conversationId) {
throw new Error('Conversation ID is required'); throw new Error('Conversation ID is required');
} }
return dataService.createSharedLink(conversationId, targetMessageId); return dataService.createSharedLink(conversationId, targetMessageId, expirationHours);
}, },
{ {
onSuccess: (_data: t.TSharedLinkResponse, vars, context) => { onSuccess: (_data: t.TSharedLinkResponse, vars, context) => {

View file

@ -900,6 +900,13 @@
"com_ui_everyone_permission_level": "Everyone's permission level", "com_ui_everyone_permission_level": "Everyone's permission level",
"com_ui_examples": "Examples", "com_ui_examples": "Examples",
"com_ui_expand_chat": "Expand Chat", "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_export_convo_modal": "Export Conversation Modal",
"com_ui_feedback_more": "More...", "com_ui_feedback_more": "More...",
"com_ui_feedback_more_information": "Provide additional feedback", "com_ui_feedback_more_information": "Provide additional feedback",
@ -1049,6 +1056,7 @@
"com_ui_more_info": "More info", "com_ui_more_info": "More info",
"com_ui_my_prompts": "My Prompts", "com_ui_my_prompts": "My Prompts",
"com_ui_name": "Name", "com_ui_name": "Name",
"com_ui_never": "Never",
"com_ui_new": "New", "com_ui_new": "New",
"com_ui_new_chat": "New chat", "com_ui_new_chat": "New chat",
"com_ui_new_conversation_title": "New Conversation Title", "com_ui_new_conversation_title": "New Conversation Title",

View file

@ -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;

View file

@ -45,8 +45,12 @@ export function getSharedLink(conversationId: string): Promise<t.TSharedLinkGetR
export function createSharedLink( export function createSharedLink(
conversationId: string, conversationId: string,
targetMessageId?: string, targetMessageId?: string,
expirationHours?: number,
): Promise<t.TSharedLinkResponse> { ): Promise<t.TSharedLinkResponse> {
return request.post(endpoints.createSharedLink(conversationId), { targetMessageId }); return request.post(endpoints.createSharedLink(conversationId), {
targetMessageId,
expirationHours,
});
} }
export function updateSharedLink(shareId: string): Promise<t.TSharedLinkResponse> { export function updateSharedLink(shareId: string): Promise<t.TSharedLinkResponse> {

View file

@ -4,6 +4,7 @@ import type { FilterQuery, Model } from 'mongoose';
import type { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili'; import type { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili';
import type * as t from '~/types'; import type * as t from '~/types';
import logger from '~/config/winston'; import logger from '~/config/winston';
import { createShareExpirationDate, isShareExpired } from '~/utils/shareExpiration';
class ShareServiceError extends Error { class ShareServiceError extends Error {
code: string; code: string;
@ -173,6 +174,15 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
return null; 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) */ /** Filtered messages based on targetMessageId if present (branch-specific sharing) */
let messagesToShare: t.IMessage[] = share.messages; let messagesToShare: t.IMessage[] = share.messages;
if (share.targetMessageId) { if (share.targetMessageId) {
@ -184,6 +194,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
shareId: share.shareId || shareId, shareId: share.shareId || shareId,
title: share.title, title: share.title,
isPublic: share.isPublic, isPublic: share.isPublic,
expiresAt: share.expiresAt,
createdAt: share.createdAt, createdAt: share.createdAt,
updatedAt: share.updatedAt, updatedAt: share.updatedAt,
conversationId: newConvoId, conversationId: newConvoId,
@ -275,6 +286,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
shareId: link.shareId || '', shareId: link.shareId || '',
title: link?.title || 'Untitled', title: link?.title || 'Untitled',
isPublic: link.isPublic, isPublic: link.isPublic,
expiresAt: link.expiresAt,
createdAt: link.createdAt || new Date(), createdAt: link.createdAt || new Date(),
conversationId: link.conversationId, conversationId: link.conversationId,
})), })),
@ -345,6 +357,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
user: string, user: string,
conversationId: string, conversationId: string,
targetMessageId?: string, targetMessageId?: string,
expirationHours?: number,
): Promise<t.CreateShareResult> { ): Promise<t.CreateShareResult> {
if (!user || !conversationId) { if (!user || !conversationId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); 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 title = conversation.title || 'Untitled';
const shareId = nanoid(); const shareId = nanoid();
const expiresAt = createShareExpirationDate(expirationHours);
await SharedLink.create({ await SharedLink.create({
shareId, shareId,
conversationId, conversationId,
@ -408,6 +422,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
title, title,
user, user,
...(targetMessageId && { targetMessageId }), ...(targetMessageId && { targetMessageId }),
...(expiresAt && { expiresAt }),
}); });
return { shareId, conversationId }; return { shareId, conversationId };

View file

@ -8,6 +8,7 @@ export interface ISharedLink extends Document {
shareId?: string; shareId?: string;
targetMessageId?: string; targetMessageId?: string;
isPublic: boolean; isPublic: boolean;
expiresAt?: Date;
createdAt?: Date; createdAt?: Date;
updatedAt?: Date; updatedAt?: Date;
} }
@ -40,10 +41,15 @@ const shareSchema: Schema<ISharedLink> = new Schema(
type: Boolean, type: Boolean,
default: true, default: true,
}, },
expiresAt: {
type: Date,
required: false,
},
}, },
{ timestamps: true }, { timestamps: true },
); );
shareSchema.index({ conversationId: 1, user: 1, targetMessageId: 1 }); shareSchema.index({ conversationId: 1, user: 1, targetMessageId: 1 });
shareSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
export default shareSchema; export default shareSchema;

View file

@ -10,6 +10,7 @@ export interface ISharedLink {
shareId?: string; shareId?: string;
targetMessageId?: string; targetMessageId?: string;
isPublic: boolean; isPublic: boolean;
expiresAt?: Date;
createdAt?: Date; createdAt?: Date;
updatedAt?: Date; updatedAt?: Date;
} }
@ -23,6 +24,7 @@ export interface SharedLinksResult {
shareId: string; shareId: string;
title: string; title: string;
isPublic: boolean; isPublic: boolean;
expiresAt?: Date;
createdAt: Date; createdAt: Date;
conversationId: string; conversationId: string;
}>; }>;
@ -36,6 +38,7 @@ export interface SharedMessagesResult {
shareId: string; shareId: string;
title?: string; title?: string;
isPublic: boolean; isPublic: boolean;
expiresAt?: Date;
createdAt?: Date; createdAt?: Date;
updatedAt?: Date; updatedAt?: Date;
} }

View file

@ -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);
}