feat: implement R2 audio file management and rate limiting enhancements

- Add R2 client setup and audio upload functionality in lib/r2.ts.
- Create audio file cleanup functions in lib/r2-cleanup.ts to delete voice files associated with videos, projects, and workspaces.
- Enhance rate limiting in lib/rate-limit.ts with new action-specific limits and improved IP validation.
- Introduce a unified rate limit check function that returns a 429 response when limits are exceeded.
- Update package.json to include the AWS SDK for S3.
This commit is contained in:
Yusuf İpek
2026-02-07 12:27:40 +03:00
parent f240689e27
commit 296c5257a7
23 changed files with 1913 additions and 181 deletions
@@ -82,6 +82,8 @@ interface Comment {
replies: { replies: {
id: string; id: string;
content: string | null; content: string | null;
voiceUrl: string | null;
voiceDuration: number | null;
createdAt: string; createdAt: string;
author: { id: string; name: string | null; image: string | null } | null; author: { id: string; name: string | null; image: string | null } | null;
guestName: string | null; guestName: string | null;
@@ -133,6 +135,19 @@ export default function VideoPage() {
const [commentText, setCommentText] = useState(''); const [commentText, setCommentText] = useState('');
const [isSubmittingComment, setIsSubmittingComment] = useState(false); const [isSubmittingComment, setIsSubmittingComment] = useState(false);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
const [voiceProgress, setVoiceProgress] = useState(0);
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0);
const [voicePlaybackRate, setVoicePlaybackRate] = useState(1);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
const voiceRafRef = useRef<number | null>(null);
const voiceKnownDurationRef = useRef<number>(0);
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null); const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
const [showResolved, setShowResolved] = useState(false); const [showResolved, setShowResolved] = useState(false);
@@ -140,6 +155,13 @@ export default function VideoPage() {
const [replyingTo, setReplyingTo] = useState<string | null>(null); const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replyText, setReplyText] = useState(''); const [replyText, setReplyText] = useState('');
const [isSubmittingReply, setIsSubmittingReply] = useState(false); const [isSubmittingReply, setIsSubmittingReply] = useState(false);
const [isReplyRecording, setIsReplyRecording] = useState(false);
const [replyRecordingTime, setReplyRecordingTime] = useState(0);
const [replyAudioBlob, setReplyAudioBlob] = useState<Blob | null>(null);
const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false);
const replyMediaRecorderRef = useRef<MediaRecorder | null>(null);
const replyAudioChunksRef = useRef<Blob[]>([]);
const replyRecordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [editingCommentId, setEditingCommentId] = useState<string | null>(null); const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [editText, setEditText] = useState(''); const [editText, setEditText] = useState('');
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false); const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
@@ -336,8 +358,9 @@ export default function VideoPage() {
} }
}, [isDragging, currentTime, handleSeekToTimestamp]); }, [isDragging, currentTime, handleSeekToTimestamp]);
const handleAddComment = useCallback(async () => { const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => {
if (!commentText.trim() || !activeVersion) return; if (!voiceData && !commentText.trim()) return;
if (!activeVersion) return;
setIsSubmittingComment(true); setIsSubmittingComment(true);
try { try {
@@ -345,8 +368,9 @@ export default function VideoPage() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
content: commentText, content: voiceData ? commentText.trim() || null : commentText,
timestamp: selectedTimestamp ?? currentTime, timestamp: selectedTimestamp ?? currentTime,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
}), }),
}); });
@@ -373,6 +397,192 @@ export default function VideoPage() {
} }
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId]); }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId]);
// Voice recording handlers
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, {
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm',
});
audioChunksRef.current = [];
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
audioChunksRef.current.push(e.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
setAudioBlob(blob);
stream.getTracks().forEach((track) => track.stop());
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
};
mediaRecorder.start(100);
setIsRecording(true);
setRecordingTime(0);
recordingTimerRef.current = setInterval(() => {
setRecordingTime((prev) => prev + 0.1);
}, 100);
} catch (err) {
console.error('Failed to start recording:', err);
}
}, []);
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
setIsRecording(false);
}, []);
const cancelRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
setIsRecording(false);
setAudioBlob(null);
setRecordingTime(0);
}, []);
const submitVoiceComment = useCallback(async () => {
if (!audioBlob || !activeVersion) return;
setIsUploadingAudio(true);
try {
// Upload audio to R2
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
const uploadRes = await fetch('/api/upload/audio', {
method: 'POST',
body: formData,
});
if (!uploadRes.ok) {
throw new Error('Failed to upload audio');
}
const { url } = await uploadRes.json();
// Submit comment with voice URL
await handleAddComment({ url, duration: recordingTime });
setAudioBlob(null);
setRecordingTime(0);
} catch (err) {
console.error('Failed to submit voice comment:', err);
} finally {
setIsUploadingAudio(false);
}
}, [audioBlob, activeVersion, recordingTime, handleAddComment]);
// Voice playback
const stopVoiceTracking = useCallback(() => {
if (voiceRafRef.current) {
cancelAnimationFrame(voiceRafRef.current);
voiceRafRef.current = null;
}
}, []);
const startVoiceTracking = useCallback(() => {
stopVoiceTracking();
const tick = () => {
const audio = audioPlayerRef.current;
if (audio) {
const dur = isFinite(audio.duration) && audio.duration > 0
? audio.duration
: voiceKnownDurationRef.current;
if (dur > 0) {
setVoiceProgress((audio.currentTime / dur) * 100);
setVoiceCurrentTime(audio.currentTime);
}
}
voiceRafRef.current = requestAnimationFrame(tick);
};
voiceRafRef.current = requestAnimationFrame(tick);
}, [stopVoiceTracking]);
const playVoice = useCallback((commentId: string, voiceUrl: string, knownDuration?: number) => {
if (playingVoiceId === commentId) {
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
audioPlayerRef.current = null;
}
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
return;
}
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
}
stopVoiceTracking();
voiceKnownDurationRef.current = knownDuration || 0;
const audio = new Audio(voiceUrl);
audio.playbackRate = voicePlaybackRate;
audioPlayerRef.current = audio;
setPlayingVoiceId(commentId);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audio.onplay = () => {
startVoiceTracking();
};
audio.onended = () => {
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audioPlayerRef.current = null;
};
audio.onerror = () => {
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audioPlayerRef.current = null;
};
audio.play();
}, [playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]);
const toggleVoiceSpeed = useCallback(() => {
setVoicePlaybackRate((prev) => {
const next = prev === 1 ? 2 : 1;
if (audioPlayerRef.current) {
audioPlayerRef.current.playbackRate = next;
}
return next;
});
}, []);
// Cleanup audio on unmount
useEffect(() => {
return () => {
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
audioPlayerRef.current = null;
}
stopVoiceTracking();
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
}
};
}, []);
const handleResolveComment = useCallback( const handleResolveComment = useCallback(
async (commentId: string, currentlyResolved: boolean) => { async (commentId: string, currentlyResolved: boolean) => {
try { try {
@@ -408,17 +618,19 @@ export default function VideoPage() {
); );
// Reply to a comment // Reply to a comment
const handleReplyComment = useCallback(async (parentId: string) => { const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => {
if (!replyText.trim() || !activeVersion) return; if (!voiceData && !replyText.trim()) return;
if (!activeVersion) return;
setIsSubmittingReply(true); setIsSubmittingReply(true);
try { try {
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, { const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
content: replyText, content: voiceData ? replyText.trim() || null : replyText,
timestamp: comments.find((c) => c.id === parentId)?.timestamp ?? currentTime, timestamp: comments.find((c) => c.id === parentId)?.timestamp ?? currentTime,
parentId, parentId,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
}), }),
}); });
if (res.ok) { if (res.ok) {
@@ -443,6 +655,8 @@ export default function VideoPage() {
}); });
setReplyText(''); setReplyText('');
setReplyingTo(null); setReplyingTo(null);
setReplyAudioBlob(null);
setReplyRecordingTime(0);
} }
} catch (err) { } catch (err) {
console.error('Failed to reply:', err); console.error('Failed to reply:', err);
@@ -451,6 +665,73 @@ export default function VideoPage() {
} }
}, [replyText, activeVersion, activeVersionId, comments, currentTime]); }, [replyText, activeVersion, activeVersionId, comments, currentTime]);
// Voice recording for replies
const startReplyRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, {
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm',
});
replyAudioChunksRef.current = [];
replyMediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) replyAudioChunksRef.current.push(e.data);
};
mediaRecorder.onstop = () => {
const blob = new Blob(replyAudioChunksRef.current, { type: 'audio/webm' });
setReplyAudioBlob(blob);
stream.getTracks().forEach((track) => track.stop());
if (replyRecordingTimerRef.current) {
clearInterval(replyRecordingTimerRef.current);
replyRecordingTimerRef.current = null;
}
};
mediaRecorder.start(100);
setIsReplyRecording(true);
setReplyRecordingTime(0);
replyRecordingTimerRef.current = setInterval(() => {
setReplyRecordingTime((prev) => prev + 0.1);
}, 100);
} catch (err) {
console.error('Failed to start reply recording:', err);
}
}, []);
const stopReplyRecording = useCallback(() => {
if (replyMediaRecorderRef.current && replyMediaRecorderRef.current.state !== 'inactive') {
replyMediaRecorderRef.current.stop();
}
setIsReplyRecording(false);
}, []);
const cancelReplyRecording = useCallback(() => {
if (replyMediaRecorderRef.current && replyMediaRecorderRef.current.state !== 'inactive') {
replyMediaRecorderRef.current.stop();
}
setIsReplyRecording(false);
setReplyAudioBlob(null);
setReplyRecordingTime(0);
}, []);
const submitVoiceReply = useCallback(async (parentId: string) => {
if (!replyAudioBlob || !activeVersion) return;
setIsUploadingReplyAudio(true);
try {
const formData = new FormData();
formData.append('audio', replyAudioBlob, 'recording.webm');
const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
if (!uploadRes.ok) throw new Error('Failed to upload audio');
const { url } = await uploadRes.json();
await handleReplyComment(parentId, { url, duration: replyRecordingTime });
} catch (err) {
console.error('Failed to submit voice reply:', err);
} finally {
setIsUploadingReplyAudio(false);
}
}, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment]);
// Edit a comment // Edit a comment
const handleEditComment = useCallback(async (commentId: string) => { const handleEditComment = useCallback(async (commentId: string) => {
if (!editText.trim()) return; if (!editText.trim()) return;
@@ -1055,15 +1336,37 @@ export default function VideoPage() {
{comment.voiceUrl && ( {comment.voiceUrl && (
<div className="flex items-center gap-2 p-2 bg-muted rounded mb-2"> <div className="flex items-center gap-2 p-2 bg-muted rounded mb-2">
<Button size="icon" variant="ghost" className="h-8 w-8"> <Button
<Play className="h-4 w-4" /> size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => playVoice(comment.id, comment.voiceUrl!, comment.voiceDuration || 0)}
>
{playingVoiceId === comment.id ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button> </Button>
<div className="flex-1 h-1 bg-primary/30 rounded"> <div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
<div className="w-0 h-full bg-primary rounded" /> <div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === comment.id ? `${voiceProgress}%` : '0%' }}
/>
</div> </div>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground tabular-nums shrink-0">
{formatTime(comment.voiceDuration || 0)} {playingVoiceId === comment.id
? `${formatTime(voiceCurrentTime)} / ${formatTime(comment.voiceDuration || 0)}`
: formatTime(comment.voiceDuration || 0)}
</span> </span>
{playingVoiceId === comment.id && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
</div> </div>
)} )}
@@ -1158,7 +1461,42 @@ export default function VideoPage() {
</div> </div>
</div> </div>
) : ( ) : (
<p className="text-sm">{reply.content}</p> reply.content && <p className="text-sm">{reply.content}</p>
)}
{reply.voiceUrl && (
<div className="flex items-center gap-2 p-1.5 bg-muted rounded mt-1">
<Button
size="icon"
variant="ghost"
className="h-6 w-6 shrink-0"
onClick={() => playVoice(reply.id, reply.voiceUrl!, reply.voiceDuration || 0)}
>
{playingVoiceId === reply.id ? (
<Pause className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
<div className="flex-1 h-1.5 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === reply.id ? `${voiceProgress}%` : '0%' }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{playingVoiceId === reply.id
? `${formatTime(voiceCurrentTime)} / ${formatTime(reply.voiceDuration || 0)}`
: formatTime(reply.voiceDuration || 0)}
</span>
{playingVoiceId === reply.id && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
</div>
)} )}
</div> </div>
); );
@@ -1169,41 +1507,126 @@ export default function VideoPage() {
{/* Inline reply form */} {/* Inline reply form */}
{isReplying && ( {isReplying && (
<div className="mt-3 pl-3 border-l-2"> <div className="mt-3 pl-3 border-l-2">
<Textarea {isReplyRecording ? (
value={replyText} <div className="flex items-center gap-2 p-2 bg-destructive/10 border border-destructive/30 rounded-lg mb-1">
onChange={(e) => setReplyText(e.target.value)} <div className="h-2 w-2 rounded-full bg-destructive animate-pulse" />
placeholder="Write a reply..." <span className="text-xs font-medium text-destructive">
rows={2} {formatTime(replyRecordingTime)}
className="resize-none text-sm mb-1" </span>
autoFocus <div className="flex-1" />
onKeyDown={(e) => { <Button size="sm" variant="destructive" onClick={stopReplyRecording} className="h-6 text-xs">
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { Stop
handleReplyComment(comment.id); </Button>
} <Button size="sm" variant="ghost" onClick={cancelReplyRecording} className="h-6 text-xs">
if (e.key === 'Escape') { <X className="h-3 w-3" />
setReplyingTo(null); </Button>
setReplyText(''); </div>
} ) : replyAudioBlob ? (
}} <div className="space-y-1 mb-1">
/> <div className="flex items-center gap-2 p-2 bg-muted rounded-lg">
<div className="flex gap-1"> <Button
<Button size="icon"
size="sm" variant="ghost"
onClick={() => handleReplyComment(comment.id)} className="h-6 w-6"
disabled={!replyText.trim() || isSubmittingReply} onClick={() => {
className="h-7 text-xs" const url = URL.createObjectURL(replyAudioBlob);
> playVoice('reply-preview', url, replyRecordingTime);
{isSubmittingReply ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Reply'} }}
</Button> >
<Button {playingVoiceId === 'reply-preview' ? <Pause className="h-3 w-3" /> : <Play className="h-3 w-3" />}
size="sm" </Button>
variant="ghost" <div className="flex-1 h-1.5 bg-primary/20 rounded-full overflow-hidden">
onClick={() => { setReplyingTo(null); setReplyText(''); }} <div
className="h-7 text-xs" className="h-full bg-primary rounded-full"
> style={{ width: playingVoiceId === 'reply-preview' ? `${voiceProgress}%` : '0%' }}
Cancel />
</Button> </div>
</div> <span className="text-xs text-muted-foreground tabular-nums">
{playingVoiceId === 'reply-preview'
? `${formatTime(voiceCurrentTime)} / ${formatTime(replyRecordingTime)}`
: formatTime(replyRecordingTime)}
</span>
{playingVoiceId === 'reply-preview' && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={cancelReplyRecording}>
<X className="h-3 w-3" />
</Button>
</div>
<Textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Add a note (optional)..."
rows={1}
className="resize-none text-sm"
/>
<div className="flex gap-1">
<Button
size="sm"
onClick={() => submitVoiceReply(comment.id)}
disabled={isUploadingReplyAudio}
className="h-7 text-xs"
>
{isUploadingReplyAudio ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Send Voice Reply'}
</Button>
<Button size="sm" variant="ghost" onClick={cancelReplyRecording} className="h-7 text-xs">Cancel</Button>
</div>
</div>
) : (
<>
<div className="flex gap-1">
<Textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Write a reply..."
rows={2}
className="resize-none text-sm flex-1"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleReplyComment(comment.id);
}
if (e.key === 'Escape') {
setReplyingTo(null);
setReplyText('');
}
}}
/>
<Button
size="icon"
variant="outline"
onClick={startReplyRecording}
title="Record voice reply"
className="h-8 w-8 shrink-0 self-end"
>
<Mic className="h-3 w-3" />
</Button>
</div>
<div className="flex gap-1 mt-1">
<Button
size="sm"
onClick={() => handleReplyComment(comment.id)}
disabled={!replyText.trim() || isSubmittingReply}
className="h-7 text-xs"
>
{isSubmittingReply ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Reply'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => { setReplyingTo(null); setReplyText(''); }}
className="h-7 text-xs"
>
Cancel
</Button>
</div>
</>
)}
</div> </div>
)} )}
@@ -1227,52 +1650,152 @@ export default function VideoPage() {
<div className="shrink-0 p-4 border-t bg-background"> <div className="shrink-0 p-4 border-t bg-background">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Button <Button
variant="outline" variant={selectedTimestamp !== null ? 'default' : 'outline'}
size="sm" size="sm"
onClick={() => setSelectedTimestamp(currentTime)} onClick={() => {
className={cn(selectedTimestamp !== null && 'border-primary')} if (selectedTimestamp !== null) {
setSelectedTimestamp(null);
} else {
setSelectedTimestamp(currentTime);
}
}}
> >
<Clock className="h-4 w-4 mr-1" /> <Clock className="h-4 w-4 mr-1" />
{selectedTimestamp !== null ? formatTime(selectedTimestamp) : formatTime(currentTime)} {selectedTimestamp !== null ? formatTime(selectedTimestamp) : formatTime(currentTime)}
{selectedTimestamp !== null && <X className="h-3 w-3 ml-1" />}
</Button> </Button>
<span className="text-xs text-muted-foreground">Pin to this time</span> <span className="text-xs text-muted-foreground">
{selectedTimestamp !== null ? 'Pinned — click to unpin' : 'Pin to this time'}
</span>
</div> </div>
<div className="flex gap-2"> {/* Recording state UI */}
<Textarea {isRecording ? (
placeholder="Add a comment..." <div className="flex items-center gap-3 p-3 bg-destructive/10 border border-destructive/30 rounded-lg">
value={commentText} <div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
onChange={(e) => setCommentText(e.target.value)} <span className="text-sm font-medium text-destructive">
rows={2} Recording {formatTime(recordingTime)}
className="resize-none text-sm" </span>
onKeyDown={(e) => { <div className="flex-1" />
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { <Button size="sm" variant="destructive" onClick={stopRecording}>
handleAddComment(); Stop
}
}}
/>
<div className="flex flex-col gap-1">
<Button
size="icon"
onClick={handleAddComment}
disabled={!commentText.trim() || isSubmittingComment}
>
{isSubmittingComment ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button> </Button>
<Button <Button size="sm" variant="ghost" onClick={cancelRecording}>
size="icon" <X className="h-4 w-4" />
variant={isRecording ? 'destructive' : 'outline'}
onClick={() => setIsRecording(!isRecording)}
>
<Mic className={cn('h-4 w-4', isRecording && 'animate-pulse')} />
</Button> </Button>
</div> </div>
</div> ) : audioBlob ? (
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p> <div className="space-y-2">
<div className="flex items-center gap-3 p-3 bg-muted rounded-lg">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => {
const url = URL.createObjectURL(audioBlob);
playVoice('preview', url, recordingTime);
}}
>
{playingVoiceId === 'preview' ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === 'preview' ? `${voiceProgress}%` : '0%' }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums">
{playingVoiceId === 'preview'
? `${formatTime(voiceCurrentTime)} / ${formatTime(recordingTime)}`
: formatTime(recordingTime)}
</span>
{playingVoiceId === 'preview' && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={cancelRecording}
>
<X className="h-4 w-4" />
</Button>
</div>
<Textarea
placeholder="Add a note to your voice comment (optional)..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={1}
className="resize-none text-sm"
/>
<Button
size="sm"
onClick={submitVoiceComment}
disabled={isUploadingAudio}
className="w-full"
>
{isUploadingAudio ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Uploading...
</>
) : (
<>
<Send className="h-4 w-4 mr-2" />
Send Voice Comment
</>
)}
</Button>
</div>
) : (
<>
<div className="flex gap-2">
<Textarea
placeholder="Add a comment..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={2}
className="resize-none text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleAddComment();
}
}}
/>
<div className="flex flex-col gap-1">
<Button
size="icon"
onClick={() => handleAddComment()}
disabled={!commentText.trim() || isSubmittingComment}
>
{isSubmittingComment ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
<Button
size="icon"
variant="outline"
onClick={startRecording}
title="Record voice comment"
>
<Mic className="h-4 w-4" />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p>
</>
)}
</div> </div>
</div> </div>
</div> </div>
+10 -1
View File
@@ -1,3 +1,12 @@
import { handlers } from '@/lib/auth'; import { handlers } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';
export const { GET, POST } = handlers; export const { GET } = handlers;
// Wrap NextAuth POST with login rate limiting
export async function POST(request: Request) {
const limited = await rateLimit(request, 'login');
if (limited) return limited;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return handlers.POST(request as any);
}
+37
View File
@@ -1,6 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ commentId: string }> }; type RouteParams = { params: Promise<{ commentId: string }> };
@@ -65,6 +68,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// PATCH /api/comments/[commentId] // PATCH /api/comments/[commentId]
export async function PATCH(request: NextRequest, { params }: RouteParams) { export async function PATCH(request: NextRequest, { params }: RouteParams) {
try { try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth(); const session = await auth();
const { commentId } = await params; const { commentId } = await params;
@@ -152,6 +158,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
// DELETE /api/comments/[commentId] // DELETE /api/comments/[commentId]
export async function DELETE(request: NextRequest, { params }: RouteParams) { export async function DELETE(request: NextRequest, { params }: RouteParams) {
try { try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth(); const session = await auth();
const { commentId } = await params; const { commentId } = await params;
@@ -162,6 +171,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
const comment = await db.comment.findUnique({ const comment = await db.comment.findUnique({
where: { id: commentId }, where: { id: commentId },
include: { include: {
replies: { select: { voiceUrl: true } },
version: { version: {
include: { include: {
video: { include: { project: true } }, video: { include: { project: true } },
@@ -184,8 +194,35 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
); );
} }
// Collect all voice URLs to delete from R2 (comment + its replies)
const voiceUrls: string[] = [];
if (comment.voiceUrl) voiceUrls.push(comment.voiceUrl);
for (const reply of comment.replies) {
if (reply.voiceUrl) voiceUrls.push(reply.voiceUrl);
}
await db.comment.delete({ where: { id: commentId } }); await db.comment.delete({ where: { id: commentId } });
// Clean up audio files from R2 (best-effort, don't block on failure)
const AUDIO_PREFIX = '/api/upload/audio/';
for (const url of voiceUrls) {
try {
// Extract filename using string parsing (safe against ReDoS)
const idx = url.indexOf(AUDIO_PREFIX);
const filename = idx !== -1 ? url.slice(idx + AUDIO_PREFIX.length) : null;
if (filename) {
await r2Client.send(
new DeleteObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: `voice/${filename}`,
})
);
}
} catch (err) {
console.error('Failed to delete audio from R2:', err);
}
}
return NextResponse.json({ success: true, message: 'Comment deleted' }); return NextResponse.json({ success: true, message: 'Comment deleted' });
} catch (error) { } catch (error) {
console.error('Error deleting comment:', error); console.error('Error deleting comment:', error);
@@ -2,12 +2,16 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { ProjectMemberRole } from '@prisma/client'; import { ProjectMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ projectId: string; memberId: string }> }; type RouteParams = { params: Promise<{ projectId: string; memberId: string }> };
// PATCH /api/projects/[projectId]/members/[memberId] - Update member role // PATCH /api/projects/[projectId]/members/[memberId] - Update member role
export async function PATCH(request: NextRequest, { params }: RouteParams) { export async function PATCH(request: NextRequest, { params }: RouteParams) {
try { try {
const limited = await rateLimit(request, 'manage-member');
if (limited) return limited;
const session = await auth(); const session = await auth();
const { projectId, memberId } = await params; const { projectId, memberId } = await params;
@@ -63,6 +67,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
// DELETE /api/projects/[projectId]/members/[memberId] - Remove member // DELETE /api/projects/[projectId]/members/[memberId] - Remove member
export async function DELETE(request: NextRequest, { params }: RouteParams) { export async function DELETE(request: NextRequest, { params }: RouteParams) {
try { try {
const limited = await rateLimit(request, 'manage-member');
if (limited) return limited;
const session = await auth(); const session = await auth();
const { projectId, memberId } = await params; const { projectId, memberId } = await params;
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { ProjectMemberRole } from '@prisma/client'; import { ProjectMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ projectId: string }> }; type RouteParams = { params: Promise<{ projectId: string }> };
@@ -59,6 +60,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// POST /api/projects/[projectId]/members - Invite a member // POST /api/projects/[projectId]/members - Invite a member
export async function POST(request: NextRequest, { params }: RouteParams) { export async function POST(request: NextRequest, { params }: RouteParams) {
try { try {
const limited = await rateLimit(request, 'invite-member');
if (limited) return limited;
const session = await auth(); const session = await auth();
const { projectId } = await params; const { projectId } = await params;
+11
View File
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { ProjectMemberRole, ProjectVisibility } from '@prisma/client'; import { ProjectMemberRole, ProjectVisibility } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
import { cleanupProjectVoiceFiles } from '@/lib/r2-cleanup';
type RouteParams = { params: Promise<{ projectId: string }> }; type RouteParams = { params: Promise<{ projectId: string }> };
@@ -117,6 +119,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// PATCH /api/projects/[projectId] - Update a project // PATCH /api/projects/[projectId] - Update a project
export async function PATCH(request: NextRequest, { params }: RouteParams) { export async function PATCH(request: NextRequest, { params }: RouteParams) {
try { try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth(); const session = await auth();
const { projectId } = await params; const { projectId } = await params;
@@ -159,6 +164,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
// DELETE /api/projects/[projectId] - Delete a project // DELETE /api/projects/[projectId] - Delete a project
export async function DELETE(request: NextRequest, { params }: RouteParams) { export async function DELETE(request: NextRequest, { params }: RouteParams) {
try { try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth(); const session = await auth();
const { projectId } = await params; const { projectId } = await params;
@@ -179,6 +187,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
); );
} }
// Clean up voice files from R2 before cascade delete removes comment rows
await cleanupProjectVoiceFiles(projectId);
await db.project.delete({ where: { id: projectId } }); await db.project.delete({ where: { id: projectId } });
return NextResponse.json({ success: true, message: 'Project deleted' }); return NextResponse.json({ success: true, message: 'Project deleted' });
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { ProjectMemberRole } from '@prisma/client'; import { ProjectMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
import { cleanupVideoVoiceFiles } from '@/lib/r2-cleanup';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> }; type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
@@ -65,6 +67,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// PATCH /api/projects/[projectId]/videos/[videoId] // PATCH /api/projects/[projectId]/videos/[videoId]
export async function PATCH(request: NextRequest, { params }: RouteParams) { export async function PATCH(request: NextRequest, { params }: RouteParams) {
try { try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth(); const session = await auth();
const { projectId, videoId } = await params; const { projectId, videoId } = await params;
@@ -122,6 +127,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
// DELETE /api/projects/[projectId]/videos/[videoId] // DELETE /api/projects/[projectId]/videos/[videoId]
export async function DELETE(request: NextRequest, { params }: RouteParams) { export async function DELETE(request: NextRequest, { params }: RouteParams) {
try { try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth(); const session = await auth();
const { projectId, videoId } = await params; const { projectId, videoId } = await params;
@@ -152,6 +160,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
); );
} }
// Clean up voice files from R2 before cascade delete removes comment rows
await cleanupVideoVoiceFiles(videoId);
await db.video.delete({ where: { id: videoId } }); await db.video.delete({ where: { id: videoId } });
return NextResponse.json({ success: true, message: 'Video deleted' }); return NextResponse.json({ success: true, message: 'Video deleted' });
@@ -3,6 +3,7 @@ import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { ProjectMemberRole } from '@prisma/client'; import { ProjectMemberRole } from '@prisma/client';
import { validateUrl, validateOptionalUrl } from '@/lib/validation'; import { validateUrl, validateOptionalUrl } from '@/lib/validation';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> }; type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
@@ -54,6 +55,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// POST /api/projects/[projectId]/videos/[videoId]/versions - Add a new version // POST /api/projects/[projectId]/videos/[videoId]/versions - Add a new version
export async function POST(request: NextRequest, { params }: RouteParams) { export async function POST(request: NextRequest, { params }: RouteParams) {
try { try {
const limited = await rateLimit(request, 'create-version');
if (limited) return limited;
const session = await auth(); const session = await auth();
const { projectId, videoId } = await params; const { projectId, videoId } = await params;
@@ -3,6 +3,7 @@ import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { ProjectMemberRole } from '@prisma/client'; import { ProjectMemberRole } from '@prisma/client';
import { validateUrl, validateOptionalUrl } from '@/lib/validation'; import { validateUrl, validateOptionalUrl } from '@/lib/validation';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ projectId: string }> }; type RouteParams = { params: Promise<{ projectId: string }> };
@@ -57,6 +58,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// POST /api/projects/[projectId]/videos - Add a new video to the project // POST /api/projects/[projectId]/videos - Add a new video to the project
export async function POST(request: NextRequest, { params }: RouteParams) { export async function POST(request: NextRequest, { params }: RouteParams) {
try { try {
const limited = await rateLimit(request, 'create-video');
if (limited) return limited;
const session = await auth(); const session = await auth();
const { projectId } = await params; const { projectId } = await params;
+4
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { ProjectVisibility } from '@prisma/client'; import { ProjectVisibility } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
// GET /api/projects - List all projects for the authenticated user // GET /api/projects - List all projects for the authenticated user
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -75,6 +76,9 @@ export async function GET(request: NextRequest) {
// POST /api/projects - Create a new project // POST /api/projects - Create a new project
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const limited = await rateLimit(request, 'create-project');
if (limited) return limited;
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from 'next/server';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { GetObjectCommand } from '@aws-sdk/client-s3';
// Only allow UUID filenames with safe extensions
const SAFE_FILENAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
export async function GET(
_request: Request,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
const { filename } = await params;
// Validate filename to prevent path traversal
if (!SAFE_FILENAME.test(filename)) {
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
}
const key = `voice/${filename}`;
const response = await r2Client.send(
new GetObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
})
);
if (!response.Body) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
const contentType = response.ContentType || 'audio/webm';
const contentLength = response.ContentLength;
// Stream the response body directly instead of buffering in memory
const stream = response.Body.transformToWebStream();
return new NextResponse(stream, {
status: 200,
headers: {
'Content-Type': contentType,
...(contentLength ? { 'Content-Length': String(contentLength) } : {}),
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
} catch (error: unknown) {
const errorName = error instanceof Error ? error.name : '';
if (errorName === 'NoSuchKey') {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
console.error('Error serving audio:', error);
return NextResponse.json(
{ error: 'Failed to retrieve audio' },
{ status: 500 }
);
}
}
+76
View File
@@ -0,0 +1,76 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto';
import { rateLimit } from '@/lib/rate-limit';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/wav'];
export async function POST(request: Request) {
try {
// Rate limit
const limited = await rateLimit(request, 'voice-upload');
if (limited) return limited;
// Require authentication
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get('audio') as File | null;
if (!file) {
return NextResponse.json({ error: 'No audio file provided' }, { status: 400 });
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ error: 'File too large. Maximum size is 10MB.' },
{ status: 400 }
);
}
// Check content type
const contentType = file.type || 'audio/webm';
if (!ALLOWED_TYPES.includes(contentType)) {
return NextResponse.json(
{ error: `Unsupported audio format: ${contentType}` },
{ status: 400 }
);
}
// Generate unique filename
const ext = contentType.split('/')[1] || 'webm';
const filename = `${randomUUID()}.${ext}`;
const key = `voice/${filename}`;
// Convert to buffer
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Upload to R2
await r2Client.send(
new PutObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
Body: buffer,
ContentType: contentType,
})
);
// Return the URL through our proxy endpoint
const voiceUrl = `/api/upload/audio/${filename}`;
return NextResponse.json({ url: voiceUrl }, { status: 201 });
} catch (error) {
console.error('Error uploading audio:', error);
return NextResponse.json(
{ error: 'Failed to upload audio' },
{ status: 500 }
);
}
}
+10 -4
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { validateOptionalUrl } from '@/lib/validation'; import { validateOptionalUrl } from '@/lib/validation';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ versionId: string }> }; type RouteParams = { params: Promise<{ versionId: string }> };
@@ -74,6 +75,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// POST /api/versions/[versionId]/comments // POST /api/versions/[versionId]/comments
export async function POST(request: NextRequest, { params }: RouteParams) { export async function POST(request: NextRequest, { params }: RouteParams) {
try { try {
const limited = await rateLimit(request, 'comment');
if (limited) return limited;
const session = await auth(); const session = await auth();
const { versionId } = await params; const { versionId } = await params;
@@ -149,10 +153,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
); );
} }
// Validate voice URL uses safe scheme // Validate voice URL uses safe scheme (allow internal /api/ paths)
const voiceUrlError = validateOptionalUrl(voiceUrl, 'Voice URL'); if (voiceUrl && !voiceUrl.startsWith('/api/')) {
if (voiceUrlError) { const voiceUrlError = validateOptionalUrl(voiceUrl, 'Voice URL');
return NextResponse.json({ error: voiceUrlError }, { status: 400 }); if (voiceUrlError) {
return NextResponse.json({ error: voiceUrlError }, { status: 400 });
}
} }
const comment = await db.comment.create({ const comment = await db.comment.create({
@@ -2,12 +2,16 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { WorkspaceMemberRole } from '@prisma/client'; import { WorkspaceMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ workspaceId: string; memberId: string }> }; type RouteParams = { params: Promise<{ workspaceId: string; memberId: string }> };
// PATCH /api/workspaces/[workspaceId]/members/[memberId] - Update member role // PATCH /api/workspaces/[workspaceId]/members/[memberId] - Update member role
export async function PATCH(request: NextRequest, { params }: RouteParams) { export async function PATCH(request: NextRequest, { params }: RouteParams) {
try { try {
const limited = await rateLimit(request, 'manage-member');
if (limited) return limited;
const session = await auth(); const session = await auth();
const { workspaceId, memberId } = await params; const { workspaceId, memberId } = await params;
@@ -64,6 +68,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
// DELETE /api/workspaces/[workspaceId]/members/[memberId] - Remove member // DELETE /api/workspaces/[workspaceId]/members/[memberId] - Remove member
export async function DELETE(request: NextRequest, { params }: RouteParams) { export async function DELETE(request: NextRequest, { params }: RouteParams) {
try { try {
const limited = await rateLimit(request, 'manage-member');
if (limited) return limited;
const session = await auth(); const session = await auth();
const { workspaceId, memberId } = await params; const { workspaceId, memberId } = await params;
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { WorkspaceMemberRole } from '@prisma/client'; import { WorkspaceMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ workspaceId: string }> }; type RouteParams = { params: Promise<{ workspaceId: string }> };
@@ -60,6 +61,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// POST /api/workspaces/[workspaceId]/members - Invite a member // POST /api/workspaces/[workspaceId]/members - Invite a member
export async function POST(request: NextRequest, { params }: RouteParams) { export async function POST(request: NextRequest, { params }: RouteParams) {
try { try {
const limited = await rateLimit(request, 'invite-member');
if (limited) return limited;
const session = await auth(); const session = await auth();
const { workspaceId } = await params; const { workspaceId } = await params;
+11
View File
@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';
import { cleanupWorkspaceVoiceFiles } from '@/lib/r2-cleanup';
type RouteParams = { params: Promise<{ workspaceId: string }> }; type RouteParams = { params: Promise<{ workspaceId: string }> };
@@ -81,6 +83,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// PATCH /api/workspaces/[workspaceId] - Update a workspace // PATCH /api/workspaces/[workspaceId] - Update a workspace
export async function PATCH(request: NextRequest, { params }: RouteParams) { export async function PATCH(request: NextRequest, { params }: RouteParams) {
try { try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth(); const session = await auth();
const { workspaceId } = await params; const { workspaceId } = await params;
@@ -122,6 +127,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
// DELETE /api/workspaces/[workspaceId] - Delete a workspace // DELETE /api/workspaces/[workspaceId] - Delete a workspace
export async function DELETE(request: NextRequest, { params }: RouteParams) { export async function DELETE(request: NextRequest, { params }: RouteParams) {
try { try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth(); const session = await auth();
const { workspaceId } = await params; const { workspaceId } = await params;
@@ -142,6 +150,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
); );
} }
// Clean up voice files from R2 before cascade delete removes comment rows
await cleanupWorkspaceVoiceFiles(workspaceId);
await db.workspace.delete({ where: { id: workspaceId } }); await db.workspace.delete({ where: { id: workspaceId } });
return NextResponse.json({ success: true, message: 'Workspace deleted' }); return NextResponse.json({ success: true, message: 'Workspace deleted' });
+4
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';
// GET /api/workspaces - List all workspaces for the authenticated user // GET /api/workspaces - List all workspaces for the authenticated user
export async function GET() { export async function GET() {
@@ -39,6 +40,9 @@ export async function GET() {
// POST /api/workspaces - Create a new workspace // POST /api/workspaces - Create a new workspace
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const limited = await rateLimit(request, 'create-workspace');
if (limited) return limited;
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
+605 -84
View File
@@ -23,6 +23,7 @@ import {
Reply, Reply,
Pencil, Pencil,
Trash2, Trash2,
X,
ArrowUpRight, ArrowUpRight,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -66,6 +67,8 @@ interface Comment {
replies: { replies: {
id: string; id: string;
content: string | null; content: string | null;
voiceUrl: string | null;
voiceDuration: number | null;
createdAt: string; createdAt: string;
author: { id: string; name: string | null; image: string | null } | null; author: { id: string; name: string | null; image: string | null } | null;
guestName: string | null; guestName: string | null;
@@ -113,6 +116,19 @@ export default function WatchPage() {
const [commentText, setCommentText] = useState(''); const [commentText, setCommentText] = useState('');
const [isSubmittingComment, setIsSubmittingComment] = useState(false); const [isSubmittingComment, setIsSubmittingComment] = useState(false);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
const [voiceProgress, setVoiceProgress] = useState(0);
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0);
const [voicePlaybackRate, setVoicePlaybackRate] = useState(1);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
const voiceRafRef = useRef<number | null>(null);
const voiceKnownDurationRef = useRef<number>(0);
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null); const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
const [showResolved, setShowResolved] = useState(false); const [showResolved, setShowResolved] = useState(false);
@@ -120,6 +136,13 @@ export default function WatchPage() {
const [replyingTo, setReplyingTo] = useState<string | null>(null); const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replyText, setReplyText] = useState(''); const [replyText, setReplyText] = useState('');
const [isSubmittingReply, setIsSubmittingReply] = useState(false); const [isSubmittingReply, setIsSubmittingReply] = useState(false);
const [isReplyRecording, setIsReplyRecording] = useState(false);
const [replyRecordingTime, setReplyRecordingTime] = useState(0);
const [replyAudioBlob, setReplyAudioBlob] = useState<Blob | null>(null);
const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false);
const replyMediaRecorderRef = useRef<MediaRecorder | null>(null);
const replyAudioChunksRef = useRef<Blob[]>([]);
const replyRecordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [editingCommentId, setEditingCommentId] = useState<string | null>(null); const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [editText, setEditText] = useState(''); const [editText, setEditText] = useState('');
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false); const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
@@ -274,8 +297,9 @@ export default function WatchPage() {
[currentTime, duration, handleSeekToTimestamp] [currentTime, duration, handleSeekToTimestamp]
); );
const handleAddComment = useCallback(async () => { const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => {
if (!commentText.trim() || !activeVersion) return; if (!voiceData && !commentText.trim()) return;
if (!activeVersion) return;
setIsSubmittingComment(true); setIsSubmittingComment(true);
try { try {
@@ -283,8 +307,9 @@ export default function WatchPage() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
content: commentText, content: voiceData ? commentText.trim() || null : commentText,
timestamp: selectedTimestamp ?? currentTime, timestamp: selectedTimestamp ?? currentTime,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
}), }),
}); });
@@ -311,6 +336,189 @@ export default function WatchPage() {
} }
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId]); }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId]);
// Voice recording handlers
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, {
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm',
});
audioChunksRef.current = [];
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
audioChunksRef.current.push(e.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
setAudioBlob(blob);
stream.getTracks().forEach((track) => track.stop());
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
};
mediaRecorder.start(100);
setIsRecording(true);
setRecordingTime(0);
recordingTimerRef.current = setInterval(() => {
setRecordingTime((prev) => prev + 0.1);
}, 100);
} catch (err) {
console.error('Failed to start recording:', err);
}
}, []);
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
setIsRecording(false);
}, []);
const cancelRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
setIsRecording(false);
setAudioBlob(null);
setRecordingTime(0);
}, []);
const submitVoiceComment = useCallback(async () => {
if (!audioBlob || !activeVersion) return;
setIsUploadingAudio(true);
try {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
const uploadRes = await fetch('/api/upload/audio', {
method: 'POST',
body: formData,
});
if (!uploadRes.ok) {
throw new Error('Failed to upload audio');
}
const { url } = await uploadRes.json();
await handleAddComment({ url, duration: recordingTime });
setAudioBlob(null);
setRecordingTime(0);
} catch (err) {
console.error('Failed to submit voice comment:', err);
} finally {
setIsUploadingAudio(false);
}
}, [audioBlob, activeVersion, recordingTime, handleAddComment]);
// Voice playback
const stopVoiceTracking = useCallback(() => {
if (voiceRafRef.current) {
cancelAnimationFrame(voiceRafRef.current);
voiceRafRef.current = null;
}
}, []);
const startVoiceTracking = useCallback(() => {
stopVoiceTracking();
const tick = () => {
const audio = audioPlayerRef.current;
if (audio) {
const dur = isFinite(audio.duration) && audio.duration > 0
? audio.duration
: voiceKnownDurationRef.current;
if (dur > 0) {
setVoiceProgress((audio.currentTime / dur) * 100);
setVoiceCurrentTime(audio.currentTime);
}
}
voiceRafRef.current = requestAnimationFrame(tick);
};
voiceRafRef.current = requestAnimationFrame(tick);
}, [stopVoiceTracking]);
const playVoice = useCallback((commentId: string, voiceUrl: string, knownDuration?: number) => {
if (playingVoiceId === commentId) {
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
audioPlayerRef.current = null;
}
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
return;
}
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
}
stopVoiceTracking();
voiceKnownDurationRef.current = knownDuration || 0;
const audio = new Audio(voiceUrl);
audio.playbackRate = voicePlaybackRate;
audioPlayerRef.current = audio;
setPlayingVoiceId(commentId);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audio.onplay = () => {
startVoiceTracking();
};
audio.onended = () => {
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audioPlayerRef.current = null;
};
audio.onerror = () => {
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audioPlayerRef.current = null;
};
audio.play();
}, [playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]);
const toggleVoiceSpeed = useCallback(() => {
setVoicePlaybackRate((prev) => {
const next = prev === 1 ? 2 : 1;
if (audioPlayerRef.current) {
audioPlayerRef.current.playbackRate = next;
}
return next;
});
}, []);
// Cleanup audio on unmount
useEffect(() => {
return () => {
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
audioPlayerRef.current = null;
}
stopVoiceTracking();
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
}
};
}, []);
const handleResolveComment = useCallback( const handleResolveComment = useCallback(
async (commentId: string, currentlyResolved: boolean) => { async (commentId: string, currentlyResolved: boolean) => {
try { try {
@@ -346,17 +554,19 @@ export default function WatchPage() {
); );
// Reply to a comment // Reply to a comment
const handleReplyComment = useCallback(async (parentId: string) => { const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => {
if (!replyText.trim() || !activeVersion) return; if (!voiceData && !replyText.trim()) return;
if (!activeVersion) return;
setIsSubmittingReply(true); setIsSubmittingReply(true);
try { try {
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, { const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
content: replyText, content: voiceData ? replyText.trim() || null : replyText,
timestamp: comments.find((c) => c.id === parentId)?.timestamp ?? currentTime, timestamp: comments.find((c) => c.id === parentId)?.timestamp ?? currentTime,
parentId, parentId,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
}), }),
}); });
if (res.ok) { if (res.ok) {
@@ -381,6 +591,8 @@ export default function WatchPage() {
}); });
setReplyText(''); setReplyText('');
setReplyingTo(null); setReplyingTo(null);
setReplyAudioBlob(null);
setReplyRecordingTime(0);
} }
} catch (err) { } catch (err) {
console.error('Failed to reply:', err); console.error('Failed to reply:', err);
@@ -389,6 +601,73 @@ export default function WatchPage() {
} }
}, [replyText, activeVersion, activeVersionId, comments, currentTime]); }, [replyText, activeVersion, activeVersionId, comments, currentTime]);
// Voice recording for replies
const startReplyRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, {
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm',
});
replyAudioChunksRef.current = [];
replyMediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) replyAudioChunksRef.current.push(e.data);
};
mediaRecorder.onstop = () => {
const blob = new Blob(replyAudioChunksRef.current, { type: 'audio/webm' });
setReplyAudioBlob(blob);
stream.getTracks().forEach((track) => track.stop());
if (replyRecordingTimerRef.current) {
clearInterval(replyRecordingTimerRef.current);
replyRecordingTimerRef.current = null;
}
};
mediaRecorder.start(100);
setIsReplyRecording(true);
setReplyRecordingTime(0);
replyRecordingTimerRef.current = setInterval(() => {
setReplyRecordingTime((prev) => prev + 0.1);
}, 100);
} catch (err) {
console.error('Failed to start reply recording:', err);
}
}, []);
const stopReplyRecording = useCallback(() => {
if (replyMediaRecorderRef.current && replyMediaRecorderRef.current.state !== 'inactive') {
replyMediaRecorderRef.current.stop();
}
setIsReplyRecording(false);
}, []);
const cancelReplyRecording = useCallback(() => {
if (replyMediaRecorderRef.current && replyMediaRecorderRef.current.state !== 'inactive') {
replyMediaRecorderRef.current.stop();
}
setIsReplyRecording(false);
setReplyAudioBlob(null);
setReplyRecordingTime(0);
}, []);
const submitVoiceReply = useCallback(async (parentId: string) => {
if (!replyAudioBlob || !activeVersion) return;
setIsUploadingReplyAudio(true);
try {
const formData = new FormData();
formData.append('audio', replyAudioBlob, 'recording.webm');
const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
if (!uploadRes.ok) throw new Error('Failed to upload audio');
const { url } = await uploadRes.json();
await handleReplyComment(parentId, { url, duration: replyRecordingTime });
} catch (err) {
console.error('Failed to submit voice reply:', err);
} finally {
setIsUploadingReplyAudio(false);
}
}, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment]);
// Edit a comment // Edit a comment
const handleEditComment = useCallback(async (commentId: string) => { const handleEditComment = useCallback(async (commentId: string) => {
if (!editText.trim()) return; if (!editText.trim()) return;
@@ -830,15 +1109,37 @@ export default function WatchPage() {
{comment.voiceUrl && ( {comment.voiceUrl && (
<div className="flex items-center gap-2 p-2 bg-muted rounded mb-2"> <div className="flex items-center gap-2 p-2 bg-muted rounded mb-2">
<Button size="icon" variant="ghost" className="h-8 w-8"> <Button
<Play className="h-4 w-4" /> size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => playVoice(comment.id, comment.voiceUrl!, comment.voiceDuration || 0)}
>
{playingVoiceId === comment.id ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button> </Button>
<div className="flex-1 h-1 bg-primary/30 rounded"> <div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
<div className="w-0 h-full bg-primary rounded" /> <div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === comment.id ? `${voiceProgress}%` : '0%' }}
/>
</div> </div>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground tabular-nums shrink-0">
{formatTime(comment.voiceDuration || 0)} {playingVoiceId === comment.id
? `${formatTime(voiceCurrentTime)} / ${formatTime(comment.voiceDuration || 0)}`
: formatTime(comment.voiceDuration || 0)}
</span> </span>
{playingVoiceId === comment.id && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
</div> </div>
)} )}
@@ -933,7 +1234,42 @@ export default function WatchPage() {
</div> </div>
</div> </div>
) : ( ) : (
<p className="text-sm">{reply.content}</p> reply.content && <p className="text-sm">{reply.content}</p>
)}
{reply.voiceUrl && (
<div className="flex items-center gap-2 p-1.5 bg-muted rounded mt-1">
<Button
size="icon"
variant="ghost"
className="h-6 w-6 shrink-0"
onClick={() => playVoice(reply.id, reply.voiceUrl!, reply.voiceDuration || 0)}
>
{playingVoiceId === reply.id ? (
<Pause className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
<div className="flex-1 h-1.5 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === reply.id ? `${voiceProgress}%` : '0%' }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{playingVoiceId === reply.id
? `${formatTime(voiceCurrentTime)} / ${formatTime(reply.voiceDuration || 0)}`
: formatTime(reply.voiceDuration || 0)}
</span>
{playingVoiceId === reply.id && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
</div>
)} )}
</div> </div>
); );
@@ -944,41 +1280,126 @@ export default function WatchPage() {
{/* Inline reply form */} {/* Inline reply form */}
{isReplying && ( {isReplying && (
<div className="mt-3 pl-3 border-l-2"> <div className="mt-3 pl-3 border-l-2">
<Textarea {isReplyRecording ? (
value={replyText} <div className="flex items-center gap-2 p-2 bg-destructive/10 border border-destructive/30 rounded-lg mb-1">
onChange={(e) => setReplyText(e.target.value)} <div className="h-2 w-2 rounded-full bg-destructive animate-pulse" />
placeholder="Write a reply..." <span className="text-xs font-medium text-destructive">
rows={2} {formatTime(replyRecordingTime)}
className="resize-none text-sm mb-1" </span>
autoFocus <div className="flex-1" />
onKeyDown={(e) => { <Button size="sm" variant="destructive" onClick={stopReplyRecording} className="h-6 text-xs">
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { Stop
handleReplyComment(comment.id); </Button>
} <Button size="sm" variant="ghost" onClick={cancelReplyRecording} className="h-6 text-xs">
if (e.key === 'Escape') { <X className="h-3 w-3" />
setReplyingTo(null); </Button>
setReplyText(''); </div>
} ) : replyAudioBlob ? (
}} <div className="space-y-1 mb-1">
/> <div className="flex items-center gap-2 p-2 bg-muted rounded-lg">
<div className="flex gap-1"> <Button
<Button size="icon"
size="sm" variant="ghost"
onClick={() => handleReplyComment(comment.id)} className="h-6 w-6"
disabled={!replyText.trim() || isSubmittingReply} onClick={() => {
className="h-7 text-xs" const url = URL.createObjectURL(replyAudioBlob);
> playVoice('reply-preview', url, replyRecordingTime);
{isSubmittingReply ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Reply'} }}
</Button> >
<Button {playingVoiceId === 'reply-preview' ? <Pause className="h-3 w-3" /> : <Play className="h-3 w-3" />}
size="sm" </Button>
variant="ghost" <div className="flex-1 h-1.5 bg-primary/20 rounded-full overflow-hidden">
onClick={() => { setReplyingTo(null); setReplyText(''); }} <div
className="h-7 text-xs" className="h-full bg-primary rounded-full"
> style={{ width: playingVoiceId === 'reply-preview' ? `${voiceProgress}%` : '0%' }}
Cancel />
</Button> </div>
</div> <span className="text-xs text-muted-foreground tabular-nums">
{playingVoiceId === 'reply-preview'
? `${formatTime(voiceCurrentTime)} / ${formatTime(replyRecordingTime)}`
: formatTime(replyRecordingTime)}
</span>
{playingVoiceId === 'reply-preview' && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={cancelReplyRecording}>
<X className="h-3 w-3" />
</Button>
</div>
<Textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Add a note (optional)..."
rows={1}
className="resize-none text-sm"
/>
<div className="flex gap-1">
<Button
size="sm"
onClick={() => submitVoiceReply(comment.id)}
disabled={isUploadingReplyAudio}
className="h-7 text-xs"
>
{isUploadingReplyAudio ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Send Voice Reply'}
</Button>
<Button size="sm" variant="ghost" onClick={cancelReplyRecording} className="h-7 text-xs">Cancel</Button>
</div>
</div>
) : (
<>
<div className="flex gap-1">
<Textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Write a reply..."
rows={2}
className="resize-none text-sm flex-1"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleReplyComment(comment.id);
}
if (e.key === 'Escape') {
setReplyingTo(null);
setReplyText('');
}
}}
/>
<Button
size="icon"
variant="outline"
onClick={startReplyRecording}
title="Record voice reply"
className="h-8 w-8 shrink-0 self-end"
>
<Mic className="h-3 w-3" />
</Button>
</div>
<div className="flex gap-1 mt-1">
<Button
size="sm"
onClick={() => handleReplyComment(comment.id)}
disabled={!replyText.trim() || isSubmittingReply}
className="h-7 text-xs"
>
{isSubmittingReply ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Reply'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => { setReplyingTo(null); setReplyText(''); }}
className="h-7 text-xs"
>
Cancel
</Button>
</div>
</>
)}
</div> </div>
)} )}
@@ -1002,52 +1423,152 @@ export default function WatchPage() {
<div className="shrink-0 p-4 border-t bg-background"> <div className="shrink-0 p-4 border-t bg-background">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Button <Button
variant="outline" variant={selectedTimestamp !== null ? 'default' : 'outline'}
size="sm" size="sm"
onClick={() => setSelectedTimestamp(currentTime)} onClick={() => {
className={cn(selectedTimestamp !== null && 'border-primary')} if (selectedTimestamp !== null) {
setSelectedTimestamp(null);
} else {
setSelectedTimestamp(currentTime);
}
}}
> >
<Clock className="h-4 w-4 mr-1" /> <Clock className="h-4 w-4 mr-1" />
{selectedTimestamp !== null ? formatTime(selectedTimestamp) : formatTime(currentTime)} {selectedTimestamp !== null ? formatTime(selectedTimestamp) : formatTime(currentTime)}
{selectedTimestamp !== null && <X className="h-3 w-3 ml-1" />}
</Button> </Button>
<span className="text-xs text-muted-foreground">Pin to this time</span> <span className="text-xs text-muted-foreground">
{selectedTimestamp !== null ? 'Pinned — click to unpin' : 'Pin to this time'}
</span>
</div> </div>
<div className="flex gap-2"> {/* Recording state UI */}
<Textarea {isRecording ? (
placeholder="Add a comment..." <div className="flex items-center gap-3 p-3 bg-destructive/10 border border-destructive/30 rounded-lg">
value={commentText} <div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
onChange={(e) => setCommentText(e.target.value)} <span className="text-sm font-medium text-destructive">
rows={2} Recording {formatTime(recordingTime)}
className="resize-none text-sm" </span>
onKeyDown={(e) => { <div className="flex-1" />
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { <Button size="sm" variant="destructive" onClick={stopRecording}>
handleAddComment(); Stop
}
}}
/>
<div className="flex flex-col gap-1">
<Button
size="icon"
onClick={handleAddComment}
disabled={!commentText.trim() || isSubmittingComment}
>
{isSubmittingComment ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button> </Button>
<Button <Button size="sm" variant="ghost" onClick={cancelRecording}>
size="icon" <X className="h-4 w-4" />
variant={isRecording ? 'destructive' : 'outline'}
onClick={() => setIsRecording(!isRecording)}
>
<Mic className={cn('h-4 w-4', isRecording && 'animate-pulse')} />
</Button> </Button>
</div> </div>
</div> ) : audioBlob ? (
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p> <div className="space-y-2">
<div className="flex items-center gap-3 p-3 bg-muted rounded-lg">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => {
const url = URL.createObjectURL(audioBlob);
playVoice('preview', url, recordingTime);
}}
>
{playingVoiceId === 'preview' ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === 'preview' ? `${voiceProgress}%` : '0%' }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums">
{playingVoiceId === 'preview'
? `${formatTime(voiceCurrentTime)} / ${formatTime(recordingTime)}`
: formatTime(recordingTime)}
</span>
{playingVoiceId === 'preview' && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={cancelRecording}
>
<X className="h-4 w-4" />
</Button>
</div>
<Textarea
placeholder="Add a note to your voice comment (optional)..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={1}
className="resize-none text-sm"
/>
<Button
size="sm"
onClick={submitVoiceComment}
disabled={isUploadingAudio}
className="w-full"
>
{isUploadingAudio ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Uploading...
</>
) : (
<>
<Send className="h-4 w-4 mr-2" />
Send Voice Comment
</>
)}
</Button>
</div>
) : (
<>
<div className="flex gap-2">
<Textarea
placeholder="Add a comment..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={2}
className="resize-none text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleAddComment();
}
}}
/>
<div className="flex flex-col gap-1">
<Button
size="icon"
onClick={() => handleAddComment()}
disabled={!commentText.trim() || isSubmittingComment}
>
{isSubmittingComment ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
<Button
size="icon"
variant="outline"
onClick={startRecording}
title="Record voice comment"
>
<Mic className="h-4 w-4" />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p>
</>
)}
</div> </div>
</div> </div>
</div> </div>
+209
View File
@@ -6,6 +6,7 @@
"name": "openframe", "name": "openframe",
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.1", "@auth/prisma-adapter": "^2.11.1",
"@aws-sdk/client-s3": "^3.985.0",
"@base-ui/react": "^1.1.0", "@base-ui/react": "^1.1.0",
"@prisma/adapter-pg": "^7.3.0", "@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0", "@prisma/client": "^7.3.0",
@@ -55,6 +56,88 @@
"@auth/prisma-adapter": ["@auth/[email protected]", "", { "dependencies": { "@auth/core": "0.41.1" }, "peerDependencies": { "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" } }, "sha512-Ke7DXP0Fy0Mlmjz/ZJLXwQash2UkA4621xCM0rMtEczr1kppLc/njCbUkHkIQ/PnmILjqSPEKeTjDPsYruvkug=="], "@auth/prisma-adapter": ["@auth/[email protected]", "", { "dependencies": { "@auth/core": "0.41.1" }, "peerDependencies": { "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" } }, "sha512-Ke7DXP0Fy0Mlmjz/ZJLXwQash2UkA4621xCM0rMtEczr1kppLc/njCbUkHkIQ/PnmILjqSPEKeTjDPsYruvkug=="],
"@aws-crypto/crc32": ["@aws-crypto/[email protected]", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/crc32c": ["@aws-crypto/[email protected]", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
"@aws-crypto/sha1-browser": ["@aws-crypto/[email protected]", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
"@aws-crypto/sha256-browser": ["@aws-crypto/[email protected]", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
"@aws-crypto/sha256-js": ["@aws-crypto/[email protected]", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
"@aws-crypto/supports-web-crypto": ["@aws-crypto/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
"@aws-crypto/util": ["@aws-crypto/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-s3": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.7", "@aws-sdk/credential-provider-node": "^3.972.6", "@aws-sdk/middleware-bucket-endpoint": "^3.972.3", "@aws-sdk/middleware-expect-continue": "^3.972.3", "@aws-sdk/middleware-flexible-checksums": "^3.972.5", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-location-constraint": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-sdk-s3": "^3.972.7", "@aws-sdk/middleware-ssec": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.7", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/signature-v4-multi-region": "3.985.0", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.985.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.5", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.1", "@smithy/eventstream-serde-browser": "^4.2.8", "@smithy/eventstream-serde-config-resolver": "^4.3.8", "@smithy/eventstream-serde-node": "^4.2.8", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-blob-browser": "^4.2.9", "@smithy/hash-node": "^4.2.8", "@smithy/hash-stream-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/md5-js": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.13", "@smithy/middleware-retry": "^4.4.30", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.9", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.29", "@smithy/util-defaults-mode-node": "^4.2.32", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-stream": "^4.5.11", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-S9TqjzzZEEIKBnC7yFpvqM7CG9ALpY5qhQ5BnDBJtdG20NoGpjKLGUUfD2wmZItuhbrcM4Z8c6m6Fg0XYIOVvw=="],
"@aws-sdk/client-sso": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.7", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.7", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.985.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.5", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.13", "@smithy/middleware-retry": "^4.4.30", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.9", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.29", "@smithy/util-defaults-mode-node": "^4.2.32", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-81J8iE8MuXhdbMfIz4sWFj64Pe41bFi/uqqmqOC5SlGv+kwoyLsyKS/rH2tW2t5buih4vTUxskRjxlqikTD4oQ=="],
"@aws-sdk/core": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws-sdk/xml-builder": "^3.972.4", "@smithy/core": "^3.22.1", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-wNZZQQNlJ+hzD49cKdo+PY6rsTDElO8yDImnrI69p2PLBa7QomeUKAJWYp9xnaR38nlHqWhMHZuYLCQ3oSX+xg=="],
"@aws-sdk/crc64-nvme": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-LxJ9PEO4gKPXzkufvIESUysykPIdrV7+Ocb9yAhbhJLE4TiAYqbCVUE+VuKP1leGR1bBfjWjYgSV5MxprlX3mQ=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/types": "^3.973.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/node-http-handler": "^4.4.9", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.11", "tslib": "^2.6.2" } }, "sha512-L2uOGtvp2x3bTcxFTpSM+GkwFIPd8pHfGWO1764icMbo7e5xJh0nfhx1UwkXLnwvocTNEf8A7jISZLYjUSNaTg=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/credential-provider-env": "^3.972.5", "@aws-sdk/credential-provider-http": "^3.972.7", "@aws-sdk/credential-provider-login": "^3.972.5", "@aws-sdk/credential-provider-process": "^3.972.5", "@aws-sdk/credential-provider-sso": "^3.972.5", "@aws-sdk/credential-provider-web-identity": "^3.972.5", "@aws-sdk/nested-clients": "3.985.0", "@aws-sdk/types": "^3.973.1", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-SdDTYE6jkARzOeL7+kudMIM4DaFnP5dZVeatzw849k4bSXDdErDS188bgeNzc/RA2WGrlEpsqHUKP6G7sVXhZg=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/nested-clients": "3.985.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-uYq1ILyTSI6ZDCMY5+vUsRM0SOCVI7kaW4wBrehVVkhAxC6y+e9rvGtnoZqCOWL1gKjTMouvsf4Ilhc5NCg1Aw=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.5", "@aws-sdk/credential-provider-http": "^3.972.7", "@aws-sdk/credential-provider-ini": "^3.972.5", "@aws-sdk/credential-provider-process": "^3.972.5", "@aws-sdk/credential-provider-sso": "^3.972.5", "@aws-sdk/credential-provider-web-identity": "^3.972.5", "@aws-sdk/types": "^3.973.1", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DZ3CnAAtSVtVz+G+ogqecaErMLgzph4JH5nYbHoBMgBkwTUV+SUcjsjOJwdBJTHu3Dm6l5LBYekZoU2nDqQk2A=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-HDKF3mVbLnuqGg6dMnzBf1VUOywE12/N286msI9YaK9mEIzdsGCtLTvrDhe3Up0R9/hGFbB+9l21/TwF5L1C6g=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/client-sso": "3.985.0", "@aws-sdk/core": "^3.973.7", "@aws-sdk/token-providers": "3.985.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-8urj3AoeNeQisjMmMBhFeiY2gxt6/7wQQbEGun0YV/OaOOiXrIudTIEYF8ZfD+NQI6X1FY5AkRsx6O/CaGiybA=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/nested-clients": "3.985.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-OK3cULuJl6c+RcDZfPpaK5o3deTOnKZbxm7pzhFNGA3fI2hF9yDih17fGRazJzGGWaDVlR9ejZrpDef4DJCEsw=="],
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-arn-parser": "^3.972.2", "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-config-provider": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg=="],
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg=="],
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.7", "@aws-sdk/crc64-nvme": "3.972.0", "@aws-sdk/types": "^3.973.1", "@smithy/is-array-buffer": "^4.2.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.11", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-SF/1MYWx67OyCrLA4icIpWUfCkdlOi8Y1KecQ9xYxkL10GMjVdPTGPnYhAg0dw5U43Y9PVUWhAV2ezOaG+0BLg=="],
"@aws-sdk/middleware-host-header": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA=="],
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-nIg64CVrsXp67vbK0U1/Is8rik3huS3QkRHn2DRDx4NldrEFMgdkZGI/+cZMKD9k4YOS110Dfu21KZLHrFA/1g=="],
"@aws-sdk/middleware-logger": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA=="],
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q=="],
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-arn-parser": "^3.972.2", "@smithy/core": "^3.22.1", "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.11", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-VtZ7tMIw18VzjG+I6D6rh2eLkJfTtByiFoCIauGDtTTPBEUMQUiGaJ/zZrPlCY6BsvLLeFKz3+E5mntgiOWmIg=="],
"@aws-sdk/middleware-ssec": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg=="],
"@aws-sdk/middleware-user-agent": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.985.0", "@smithy/core": "^3.22.1", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-HUD+geASjXSCyL/DHPQc/Ua7JhldTcIglVAoCV8kiVm99IaFSlAbTvEnyhZwdE6bdFyTL+uIaWLaCFSRsglZBQ=="],
"@aws-sdk/nested-clients": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.7", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.7", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.985.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.5", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.13", "@smithy/middleware-retry": "^4.4.30", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.9", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.29", "@smithy/util-defaults-mode-node": "^4.2.32", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-TsWwKzb/2WHafAY0CE7uXgLj0FmnkBTgfioG9HO+7z/zCPcl1+YU+i7dW4o0y+aFxFgxTMG+ExBQpqT/k2ao8g=="],
"@aws-sdk/region-config-resolver": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/config-resolver": "^4.4.6", "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.7", "@aws-sdk/types": "^3.973.1", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-W6hTSOPiSbh4IdTYVxN7xHjpCh0qvfQU1GKGBzGQm0ZEIOaMmWqiDEvFfyGYKmfBvumT8vHKxQRTX0av9omtIg=="],
"@aws-sdk/token-providers": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/nested-clients": "3.985.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-+hwpHZyEq8k+9JL2PkE60V93v2kNhUIv7STFt+EAez1UJsJOQDhc5LpzEX66pNjclI5OTwBROs/DhJjC/BtMjQ=="],
"@aws-sdk/types": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
"@aws-sdk/util-arn-parser": ["@aws-sdk/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg=="],
"@aws-sdk/util-endpoints": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-vth7UfGSUR3ljvaq8V4Rc62FsM7GUTH/myxPWkaEgOrprz1/Pc72EgTXxj+cPPPDAfHFIpjhkB7T7Td0RJx+BA=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog=="],
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw=="],
"@aws-sdk/util-user-agent-node": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.7", "@aws-sdk/types": "^3.973.1", "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-GsUDF+rXyxDZkkJxUsDxnA67FG+kc5W1dnloCFLl6fWzceevsCYzJpASBzT+BPjwUgREE6FngfJYYYMQUY5fZQ=="],
"@aws-sdk/xml-builder": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.3.4", "tslib": "^2.6.2" } }, "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q=="],
"@aws/lambda-invoke-store": ["@aws/[email protected]", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="],
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], "@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
@@ -453,6 +536,108 @@
"@sindresorhus/merge-streams": ["@sindresorhus/[email protected]", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], "@sindresorhus/merge-streams": ["@sindresorhus/[email protected]", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"@smithy/abort-controller": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw=="],
"@smithy/chunked-blob-reader": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA=="],
"@smithy/chunked-blob-reader-native": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ=="],
"@smithy/config-resolver": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ=="],
"@smithy/core": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.11", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-x3ie6Crr58MWrm4viHqqy2Du2rHYZjwu8BekasrQx4ca+Y24dzVAwq3yErdqIbc2G3I0kLQA13PQ+/rde+u65g=="],
"@smithy/credential-provider-imds": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw=="],
"@smithy/eventstream-codec": ["@smithy/[email protected]", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
"@smithy/eventstream-serde-browser": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw=="],
"@smithy/eventstream-serde-config-resolver": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ=="],
"@smithy/eventstream-serde-node": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A=="],
"@smithy/eventstream-serde-universal": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ=="],
"@smithy/fetch-http-handler": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA=="],
"@smithy/hash-blob-browser": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.0", "@smithy/chunked-blob-reader-native": "^4.2.1", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg=="],
"@smithy/hash-node": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA=="],
"@smithy/hash-stream-node": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w=="],
"@smithy/invalid-dependency": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ=="],
"@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ=="],
"@smithy/md5-js": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ=="],
"@smithy/middleware-content-length": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A=="],
"@smithy/middleware-endpoint": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.22.1", "@smithy/middleware-serde": "^4.2.9", "@smithy/node-config-provider": "^4.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-x6vn0PjYmGdNuKh/juUJJewZh7MoQ46jYaJ2mvekF4EesMuFfrl4LaW/k97Zjf8PTCPQmPgMvwewg7eNoH9n5w=="],
"@smithy/middleware-retry": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/service-error-classification": "^4.2.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-CBGyFvN0f8hlnqKH/jckRDz78Snrp345+PVk8Ux7pnkUCW97Iinse59lY78hBt04h1GZ6hjBN94BRwZy1xC8Bg=="],
"@smithy/middleware-serde": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ=="],
"@smithy/middleware-stack": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA=="],
"@smithy/node-config-provider": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg=="],
"@smithy/node-http-handler": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/abort-controller": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-KX5Wml5mF+luxm1szW4QDz32e3NObgJ4Fyw+irhph4I/2geXwUy4jkIMUs5ZPGflRBeR6BUkC2wqIab4Llgm3w=="],
"@smithy/property-provider": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w=="],
"@smithy/protocol-http": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ=="],
"@smithy/querystring-builder": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw=="],
"@smithy/querystring-parser": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA=="],
"@smithy/service-error-classification": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0" } }, "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ=="],
"@smithy/shared-ini-file-loader": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg=="],
"@smithy/signature-v4": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg=="],
"@smithy/smithy-client": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.22.1", "@smithy/middleware-endpoint": "^4.4.13", "@smithy/middleware-stack": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.11", "tslib": "^2.6.2" } }, "sha512-SCkGmFak/xC1n7hKRsUr6wOnBTJ3L22Qd4e8H1fQIuKTAjntwgU8lrdMe7uHdiT2mJAOWA/60qaW9tiMu69n1A=="],
"@smithy/types": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
"@smithy/url-parser": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA=="],
"@smithy/util-base64": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ=="],
"@smithy/util-body-length-browser": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg=="],
"@smithy/util-body-length-node": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA=="],
"@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew=="],
"@smithy/util-config-provider": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q=="],
"@smithy/util-defaults-mode-browser": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-nIGy3DNRmOjaYaaKcQDzmWsro9uxlaqUOhZDHQed9MW/GmkBZPtnU70Pu1+GT9IBmUXwRdDuiyaeiy9Xtpn3+Q=="],
"@smithy/util-defaults-mode-node": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/config-resolver": "^4.4.6", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-7dtFff6pu5fsjqrVve0YMhrnzJtccCWDacNKOkiZjJ++fmjGExmmSu341x+WU6Oc1IccL7lDuaUj7SfrHpWc5Q=="],
"@smithy/util-endpoints": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw=="],
"@smithy/util-hex-encoding": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw=="],
"@smithy/util-middleware": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A=="],
"@smithy/util-retry": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg=="],
"@smithy/util-stream": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.9", "@smithy/node-http-handler": "^4.4.9", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-lKmZ0S/3Qj2OF5H1+VzvDLb6kRxGzZHq6f3rAsoSu5cTLGsn3v3VQBA8czkNNXlLjoFEtVu3OQT2jEeOtOE2CA=="],
"@smithy/util-uri-escape": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA=="],
"@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw=="],
"@smithy/util-waiter": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/abort-controller": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg=="],
"@smithy/uuid": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="],
"@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@swc/helpers": ["@swc/[email protected]", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@swc/helpers": ["@swc/[email protected]", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
@@ -631,6 +816,8 @@
"body-parser": ["[email protected]", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "body-parser": ["[email protected]", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"bowser": ["[email protected]", "", {}, "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw=="],
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["[email protected]", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "braces": ["[email protected]", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
@@ -863,6 +1050,8 @@
"fast-uri": ["[email protected]", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], "fast-uri": ["[email protected]", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-xml-parser": ["[email protected]", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA=="],
"fastq": ["[email protected]", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fastq": ["[email protected]", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -1509,6 +1698,8 @@
"strip-json-comments": ["[email protected]", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "strip-json-comments": ["[email protected]", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"strnum": ["[email protected]", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="],
"styled-jsx": ["[email protected]", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], "styled-jsx": ["[email protected]", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"supports-color": ["[email protected]", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-color": ["[email protected]", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -1645,6 +1836,12 @@
"zod-validation-error": ["[email protected]", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], "zod-validation-error": ["[email protected]", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@dotenvx/dotenvx/commander": ["[email protected]", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/commander": ["[email protected]", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"@dotenvx/dotenvx/execa": ["[email protected]", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "@dotenvx/dotenvx/execa": ["[email protected]", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
@@ -1753,6 +1950,12 @@
"yargs/string-width": ["[email protected]", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "yargs/string-width": ["[email protected]", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@dotenvx/dotenvx/execa/get-stream": ["[email protected]", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@dotenvx/dotenvx/execa/get-stream": ["[email protected]", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"@dotenvx/dotenvx/execa/human-signals": ["[email protected]", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], "@dotenvx/dotenvx/execa/human-signals": ["[email protected]", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
@@ -1791,6 +1994,12 @@
"yargs/string-width/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "yargs/string-width/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"yargs/string-width/strip-ansi/ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "yargs/string-width/strip-ansi/ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
} }
} }
+104
View File
@@ -0,0 +1,104 @@
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { db } from '@/lib/db';
/** The path prefix for audio URLs served by the upload API. */
const AUDIO_PATH_PREFIX = '/api/upload/audio/';
/**
* Extract the R2 object key from a voice URL like /api/upload/audio/filename.webm.
* Uses string parsing instead of regex to avoid ReDoS risk on untrusted input.
*/
function voiceUrlToKey(url: string): string | null {
const idx = url.indexOf(AUDIO_PATH_PREFIX);
if (idx === -1) return null;
const filename = url.slice(idx + AUDIO_PATH_PREFIX.length);
return filename ? `voice/${filename}` : null;
}
/**
* Delete a list of voice files from R2 (best-effort, logs failures).
*/
async function deleteVoiceFiles(voiceUrls: string[]) {
for (const url of voiceUrls) {
try {
const key = voiceUrlToKey(url);
if (key) {
await r2Client.send(
new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: key })
);
}
} catch (err) {
console.error('Failed to delete audio from R2:', err);
}
}
}
/**
* Collect all voice URLs from comments under a given video (all versions).
*/
export async function collectVideoVoiceUrls(videoId: string): Promise<string[]> {
const comments = await db.comment.findMany({
where: {
voiceUrl: { not: null },
version: { videoParentId: videoId },
},
select: { voiceUrl: true },
});
return comments.map((c) => c.voiceUrl).filter(Boolean) as string[];
}
/**
* Collect all voice URLs from comments under all videos in a project.
*/
export async function collectProjectVoiceUrls(projectId: string): Promise<string[]> {
const comments = await db.comment.findMany({
where: {
voiceUrl: { not: null },
version: { video: { projectId } },
},
select: { voiceUrl: true },
});
return comments.map((c) => c.voiceUrl).filter(Boolean) as string[];
}
/**
* Collect all voice URLs from comments under all projects in a workspace.
*/
export async function collectWorkspaceVoiceUrls(workspaceId: string): Promise<string[]> {
const comments = await db.comment.findMany({
where: {
voiceUrl: { not: null },
version: { video: { project: { workspaceId } } },
},
select: { voiceUrl: true },
});
return comments.map((c) => c.voiceUrl).filter(Boolean) as string[];
}
/**
* Delete all voice files for a video from R2.
* Call BEFORE deleting the video from the database (cascade would remove comment rows).
*/
export async function cleanupVideoVoiceFiles(videoId: string) {
const urls = await collectVideoVoiceUrls(videoId);
await deleteVoiceFiles(urls);
}
/**
* Delete all voice files for a project from R2.
* Call BEFORE deleting the project from the database.
*/
export async function cleanupProjectVoiceFiles(projectId: string) {
const urls = await collectProjectVoiceUrls(projectId);
await deleteVoiceFiles(urls);
}
/**
* Delete all voice files for a workspace from R2.
* Call BEFORE deleting the workspace from the database.
*/
export async function cleanupWorkspaceVoiceFiles(workspaceId: string) {
const urls = await collectWorkspaceVoiceUrls(workspaceId);
await deleteVoiceFiles(urls);
}
+42
View File
@@ -0,0 +1,42 @@
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID!;
const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID!;
const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY!;
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME!;
const R2_ENDPOINT = process.env.R2_ENDPOINT;
export const r2Client = new S3Client({
region: 'auto',
endpoint: R2_ENDPOINT || `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
},
});
export async function uploadAudio(
buffer: Buffer,
filename: string,
contentType: string = 'audio/webm'
): Promise<string> {
// Sanitize: strip any path components, use only the basename
const sanitized = filename.replace(/^.*[\\/]/, '').replace(/\.\.+/g, '');
if (!sanitized) throw new Error('Invalid filename');
const key = `voice/${sanitized}`;
await r2Client.send(
new PutObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
Body: buffer,
ContentType: contentType,
})
);
// R2 public URL — uses the R2.dev subdomain or custom domain
// For development, we use the R2.dev auto-generated URL
return `https://${R2_BUCKET_NAME}.${R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${key}`;
}
export { R2_BUCKET_NAME };
+83 -8
View File
@@ -1,4 +1,5 @@
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { NextResponse } from 'next/server';
interface RateLimitConfig { interface RateLimitConfig {
windowMs: number; // Time window in milliseconds windowMs: number; // Time window in milliseconds
@@ -11,11 +12,29 @@ interface RateLimitResult {
resetAt: Date; resetAt: Date;
} }
// Default configs for different actions // Industry-standard rate limit defaults per action
export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = { export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
register: { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour // Auth — strict to prevent brute force / credential stuffing
login: { windowMs: 15 * 60 * 1000, maxRequests: 10 }, // 10 per 15 min register: { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute login: { windowMs: 15 * 60 * 1000, maxRequests: 10 }, // 10 per 15 min
// Content creation — moderate limits
comment: { windowMs: 60 * 1000, maxRequests: 15 }, // 15 per minute
'voice-upload': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'create-project': { windowMs: 60 * 60 * 1000, maxRequests: 20 }, // 20 per hour
'create-video': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'create-version': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'create-workspace': { windowMs: 60 * 60 * 1000, maxRequests: 10 }, // 10 per hour
// Member management
'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
'manage-member': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
// Mutations (update/delete) — moderate
'mutate': { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute
// General reads — generous
api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute
}; };
/** /**
@@ -30,6 +49,13 @@ export async function checkRateLimit(
const { windowMs, maxRequests } = config || RATE_LIMIT_CONFIGS[action] || RATE_LIMIT_CONFIGS.api; const { windowMs, maxRequests } = config || RATE_LIMIT_CONFIGS[action] || RATE_LIMIT_CONFIGS.api;
const windowSeconds = Math.floor(windowMs / 1000); const windowSeconds = Math.floor(windowMs / 1000);
// Validate inputs before passing to query — defence in depth.
// Prisma's tagged template $queryRaw already parameterizes these values,
// but we enforce sane bounds to reject obviously malicious input.
if (key.length > 256 || action.length > 64) {
return { allowed: true, remaining: maxRequests, resetAt: new Date(Date.now() + windowMs) };
}
try { try {
// Atomic upsert with window check // Atomic upsert with window check
// If window expired, reset count; otherwise increment // If window expired, reset count; otherwise increment
@@ -72,18 +98,39 @@ export async function checkRateLimit(
} }
} }
// Basic IP format validation — IPv4 or IPv6 (loose check, rejects obvious garbage)
const IP_PATTERN = /^[\da-fA-F.:]+$/;
function isPlausibleIp(value: string): boolean {
return value.length <= 45 && IP_PATTERN.test(value);
}
/** /**
* Get client IP from request headers * Get client IP from request headers.
* Handles common proxy headers *
* Header priority:
* 1. cf-connecting-ip — set by Cloudflare (trusted proxy); cannot be spoofed by clients
* 2. x-forwarded-for — first entry, trusted only behind a proxy that overwrites it
* 3. x-real-ip — set by some reverse proxies (Nginx)
* 4. 127.0.0.1 — local development fallback
*
* Deployed behind Cloudflare, so cf-connecting-ip is the canonical source.
*/ */
export function getClientIp(request: Request): string { export function getClientIp(request: Request): string {
// Cloudflare always sets this to the true client IP
const cfIp = request.headers.get('cf-connecting-ip');
if (cfIp && isPlausibleIp(cfIp)) {
return cfIp;
}
const forwardedFor = request.headers.get('x-forwarded-for'); const forwardedFor = request.headers.get('x-forwarded-for');
if (forwardedFor) { if (forwardedFor) {
return forwardedFor.split(',')[0].trim(); const first = forwardedFor.split(',')[0].trim();
if (isPlausibleIp(first)) return first;
} }
const realIp = request.headers.get('x-real-ip'); const realIp = request.headers.get('x-real-ip');
if (realIp) { if (realIp && isPlausibleIp(realIp)) {
return realIp; return realIp;
} }
@@ -112,3 +159,31 @@ export async function cleanupRateLimits(): Promise<void> {
console.error('Rate limit cleanup failed:', error); console.error('Rate limit cleanup failed:', error);
} }
} }
/**
* One-call rate limit check that returns a 429 NextResponse if blocked, or null if allowed.
* Use at the top of any API handler:
* const limited = await rateLimit(request, 'comment');
* if (limited) return limited;
*/
export async function rateLimit(
request: Request,
action: string,
config?: RateLimitConfig
): Promise<NextResponse | null> {
const ip = getClientIp(request);
const cfg = config || RATE_LIMIT_CONFIGS[action] || RATE_LIMIT_CONFIGS.api;
const result = await checkRateLimit(ip, action, cfg);
if (!result.allowed) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{
status: 429,
headers: rateLimitHeaders(result, cfg.maxRequests),
}
);
}
return null;
}
+1
View File
@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.1", "@auth/prisma-adapter": "^2.11.1",
"@aws-sdk/client-s3": "^3.985.0",
"@base-ui/react": "^1.1.0", "@base-ui/react": "^1.1.0",
"@prisma/adapter-pg": "^7.3.0", "@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0", "@prisma/client": "^7.3.0",