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

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

View file

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

View file

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

View file

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

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