mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
wip: shared links ttl prototype
This commit is contained in:
parent
8bdc808074
commit
3d05d22e90
12 changed files with 234 additions and 13 deletions
|
|
@ -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 #
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{!shareId && (
|
||||
<Button disabled={isCreateLoading} variant="submit" onClick={createShareLink}>
|
||||
{!isCreateLoading && localize('com_ui_create_link')}
|
||||
{isCreateLoading && <Spinner className="size-4" />}
|
||||
</Button>
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="expiry-select" className="text-sm">
|
||||
{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 && (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -232,6 +232,28 @@ export default function SharedLinks() {
|
|||
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',
|
||||
header: () => (
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
32
client/src/utils/shareExpiry.ts
Normal file
32
client/src/utils/shareExpiry.ts
Normal 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;
|
||||
|
|
@ -45,8 +45,12 @@ export function getSharedLink(conversationId: string): Promise<t.TSharedLinkGetR
|
|||
export function createSharedLink(
|
||||
conversationId: string,
|
||||
targetMessageId?: string,
|
||||
expirationHours?: number,
|
||||
): 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> {
|
||||
|
|
|
|||
|
|
@ -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<t.CreateShareResult> {
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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<ISharedLink> = 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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
87
packages/data-schemas/src/utils/shareExpiration.ts
Normal file
87
packages/data-schemas/src/utils/shareExpiration.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue