Add support for Lottie stickers and enhance disk space API endpoint
- Introduced Lottie sticker component with placeholder handling in DiscordLogs. - Expanded Discord message types in DiscordLogs component. - Implemented disk space fetching in MediaRequestForm with visual indicator. - Enhanced API for fetching Discord messages to include Lottie data for stickers. - Added disk space API endpoint with authentication and authorization checks.
This commit is contained in:
@@ -916,13 +916,26 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-sticker-lottie svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-sticker-placeholder {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
background: rgba(88, 101, 242, 0.1);
|
background: rgba(88, 101, 242, 0.1);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text-muted, #72767d);
|
color: var(--text-muted, #72767d);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .discord-sticker-lottie {
|
[data-theme="dark"] .discord-sticker-placeholder {
|
||||||
background: rgba(88, 101, 242, 0.15);
|
background: rgba(88, 101, 242, 0.15);
|
||||||
color: #b9bbbe;
|
color: #b9bbbe;
|
||||||
}
|
}
|
||||||
@@ -1762,6 +1775,35 @@ a.discord-embed-title:hover {
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Havoc bot disclaimer */
|
||||||
|
.discord-havoc-disclaimer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin: 1rem 1rem 0.5rem;
|
||||||
|
background: rgba(250, 168, 26, 0.1);
|
||||||
|
border: 1px solid rgba(250, 168, 26, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #b5851b;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-havoc-disclaimer svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #faa81a;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .discord-havoc-disclaimer {
|
||||||
|
background: rgba(250, 168, 26, 0.15);
|
||||||
|
border-color: rgba(250, 168, 26, 0.4);
|
||||||
|
color: #f0c674;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .discord-havoc-disclaimer svg {
|
||||||
|
color: #faa81a;
|
||||||
|
}
|
||||||
|
|
||||||
/* Pagination / Load more */
|
/* Pagination / Load more */
|
||||||
.discord-load-more {
|
.discord-load-more {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1966,6 +2008,11 @@ a.discord-embed-title:hover {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.discord-system-unknown {
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .discord-system-message {
|
[data-theme="dark"] .discord-system-message {
|
||||||
color: #949ba4;
|
color: #949ba4;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,36 @@ import { authFetch } from '@/utils/authFetch';
|
|||||||
// Discord Message Type Constants
|
// Discord Message Type Constants
|
||||||
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||||
const MESSAGE_TYPE_DEFAULT = 0;
|
const MESSAGE_TYPE_DEFAULT = 0;
|
||||||
|
const MESSAGE_TYPE_RECIPIENT_ADD = 1;
|
||||||
|
const MESSAGE_TYPE_RECIPIENT_REMOVE = 2;
|
||||||
|
const MESSAGE_TYPE_CALL = 3;
|
||||||
|
const MESSAGE_TYPE_CHANNEL_NAME_CHANGE = 4;
|
||||||
|
const MESSAGE_TYPE_CHANNEL_ICON_CHANGE = 5;
|
||||||
|
const MESSAGE_TYPE_CHANNEL_PINNED_MESSAGE = 6;
|
||||||
|
const MESSAGE_TYPE_USER_JOIN = 7;
|
||||||
|
const MESSAGE_TYPE_GUILD_BOOST = 8;
|
||||||
|
const MESSAGE_TYPE_GUILD_BOOST_TIER_1 = 9;
|
||||||
|
const MESSAGE_TYPE_GUILD_BOOST_TIER_2 = 10;
|
||||||
|
const MESSAGE_TYPE_GUILD_BOOST_TIER_3 = 11;
|
||||||
|
const MESSAGE_TYPE_CHANNEL_FOLLOW_ADD = 12;
|
||||||
|
const MESSAGE_TYPE_GUILD_DISCOVERY_DISQUALIFIED = 14;
|
||||||
|
const MESSAGE_TYPE_GUILD_DISCOVERY_REQUALIFIED = 15;
|
||||||
|
const MESSAGE_TYPE_GUILD_DISCOVERY_GRACE_PERIOD_INITIAL_WARNING = 16;
|
||||||
|
const MESSAGE_TYPE_GUILD_DISCOVERY_GRACE_PERIOD_FINAL_WARNING = 17;
|
||||||
|
const MESSAGE_TYPE_THREAD_CREATED = 18;
|
||||||
const MESSAGE_TYPE_REPLY = 19;
|
const MESSAGE_TYPE_REPLY = 19;
|
||||||
const MESSAGE_TYPE_CHAT_INPUT_COMMAND = 20;
|
const MESSAGE_TYPE_CHAT_INPUT_COMMAND = 20;
|
||||||
|
const MESSAGE_TYPE_THREAD_STARTER_MESSAGE = 21;
|
||||||
|
const MESSAGE_TYPE_GUILD_INVITE_REMINDER = 22;
|
||||||
const MESSAGE_TYPE_CONTEXT_MENU_COMMAND = 23;
|
const MESSAGE_TYPE_CONTEXT_MENU_COMMAND = 23;
|
||||||
|
const MESSAGE_TYPE_AUTO_MODERATION_ACTION = 24;
|
||||||
|
const MESSAGE_TYPE_ROLE_SUBSCRIPTION_PURCHASE = 25;
|
||||||
|
const MESSAGE_TYPE_INTERACTION_PREMIUM_UPSELL = 26;
|
||||||
|
const MESSAGE_TYPE_STAGE_START = 27;
|
||||||
|
const MESSAGE_TYPE_STAGE_END = 28;
|
||||||
|
const MESSAGE_TYPE_STAGE_SPEAKER = 29;
|
||||||
|
const MESSAGE_TYPE_STAGE_TOPIC = 31;
|
||||||
|
const MESSAGE_TYPE_GUILD_APPLICATION_PREMIUM_SUBSCRIPTION = 32;
|
||||||
const MESSAGE_TYPE_POLL_RESULT = 46;
|
const MESSAGE_TYPE_POLL_RESULT = 46;
|
||||||
|
|
||||||
// Image modal context for child components to trigger modal
|
// Image modal context for child components to trigger modal
|
||||||
@@ -907,6 +934,60 @@ const LinkPreview = memo(function LinkPreview({ url, cachedPreview, onPreviewLoa
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Lottie Sticker Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const LottieSticker = memo(function LottieSticker({ data, name }) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const animationRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || !data) return;
|
||||||
|
|
||||||
|
// Dynamically import lottie-web only when needed
|
||||||
|
import('lottie-web').then((lottie) => {
|
||||||
|
// Clean up any existing animation
|
||||||
|
if (animationRef.current) {
|
||||||
|
animationRef.current.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
animationRef.current = lottie.default.loadAnimation({
|
||||||
|
container: containerRef.current,
|
||||||
|
renderer: 'svg',
|
||||||
|
loop: true,
|
||||||
|
autoplay: true,
|
||||||
|
animationData: data,
|
||||||
|
});
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('Failed to load lottie-web:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationRef.current) {
|
||||||
|
animationRef.current.destroy();
|
||||||
|
animationRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="discord-sticker-placeholder">
|
||||||
|
<span>{name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="discord-sticker-lottie"
|
||||||
|
aria-label={name}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Attachment Component
|
// Attachment Component
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1205,19 +1286,109 @@ const DiscordMessage = memo(function DiscordMessage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Default system message rendering
|
// Default system message rendering
|
||||||
|
// Generate system message text based on type
|
||||||
|
const getSystemMessageText = () => {
|
||||||
|
const username = displayAuthor?.displayName || displayAuthor?.username || 'Someone';
|
||||||
|
switch (type) {
|
||||||
|
case MESSAGE_TYPE_USER_JOIN:
|
||||||
|
// Discord has multiple join messages, pick randomly or use content if provided
|
||||||
|
const joinMessages = [
|
||||||
|
'joined the server.',
|
||||||
|
'just joined the server - glhf!',
|
||||||
|
'just joined. Everyone, look busy!',
|
||||||
|
'just arrived.',
|
||||||
|
'just landed.',
|
||||||
|
'just slid into the server.',
|
||||||
|
'hopped into the server.',
|
||||||
|
'appeared.',
|
||||||
|
];
|
||||||
|
return content || joinMessages[0];
|
||||||
|
case MESSAGE_TYPE_GUILD_BOOST:
|
||||||
|
return content || 'just boosted the server!';
|
||||||
|
case MESSAGE_TYPE_GUILD_BOOST_TIER_1:
|
||||||
|
return content || 'just boosted the server! Server has achieved Level 1!';
|
||||||
|
case MESSAGE_TYPE_GUILD_BOOST_TIER_2:
|
||||||
|
return content || 'just boosted the server! Server has achieved Level 2!';
|
||||||
|
case MESSAGE_TYPE_GUILD_BOOST_TIER_3:
|
||||||
|
return content || 'just boosted the server! Server has achieved Level 3!';
|
||||||
|
case MESSAGE_TYPE_CHANNEL_PINNED_MESSAGE:
|
||||||
|
return content || 'pinned a message to this channel.';
|
||||||
|
case MESSAGE_TYPE_THREAD_CREATED:
|
||||||
|
return content || 'started a thread.';
|
||||||
|
case MESSAGE_TYPE_RECIPIENT_ADD:
|
||||||
|
return content || 'added someone to the group.';
|
||||||
|
case MESSAGE_TYPE_RECIPIENT_REMOVE:
|
||||||
|
return content || 'removed someone from the group.';
|
||||||
|
case MESSAGE_TYPE_CALL:
|
||||||
|
return content || 'started a call.';
|
||||||
|
case MESSAGE_TYPE_CHANNEL_NAME_CHANGE:
|
||||||
|
return content || 'changed the channel name.';
|
||||||
|
case MESSAGE_TYPE_CHANNEL_ICON_CHANGE:
|
||||||
|
return content || 'changed the channel icon.';
|
||||||
|
case MESSAGE_TYPE_CHANNEL_FOLLOW_ADD:
|
||||||
|
return content || 'added a channel follow.';
|
||||||
|
case MESSAGE_TYPE_STAGE_START:
|
||||||
|
return content || 'started a Stage.';
|
||||||
|
case MESSAGE_TYPE_STAGE_END:
|
||||||
|
return content || 'ended the Stage.';
|
||||||
|
case MESSAGE_TYPE_STAGE_SPEAKER:
|
||||||
|
return content || 'is now a speaker.';
|
||||||
|
case MESSAGE_TYPE_STAGE_TOPIC:
|
||||||
|
return content || 'changed the Stage topic.';
|
||||||
|
default:
|
||||||
|
return content || '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const systemText = getSystemMessageText();
|
||||||
|
// Choose icon based on message type
|
||||||
|
const getSystemIcon = () => {
|
||||||
|
switch (type) {
|
||||||
|
case MESSAGE_TYPE_USER_JOIN:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||||||
|
<path d="M13 9h-2V7h2m0 10h-2v-6h2m-1-9A10 10 0 0 0 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2m0 3l4.5 4.5-1.41 1.41L12 7.83l-3.09 3.08L7.5 9.5 12 5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case MESSAGE_TYPE_GUILD_BOOST:
|
||||||
|
case MESSAGE_TYPE_GUILD_BOOST_TIER_1:
|
||||||
|
case MESSAGE_TYPE_GUILD_BOOST_TIER_2:
|
||||||
|
case MESSAGE_TYPE_GUILD_BOOST_TIER_3:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="#ff73fa" width="16" height="16">
|
||||||
|
<path d="M12.001 2.5c.512 0 .994.228 1.32.624l.088.114 8.5 12c.25.353.32.805.187 1.214l-.055.134-.061.114a1.5 1.5 0 01-1.191.79l-.119.01H3.331a1.5 1.5 0 01-1.398-2.048l.061-.114 8.5-12a1.5 1.5 0 011.408-.838l.099.01.1.014zM12 7.268L5.416 16.5h13.169L12 7.268z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case MESSAGE_TYPE_CHANNEL_PINNED_MESSAGE:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||||||
|
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||||||
|
<path d="M18.5 12c0-1.77-.77-3.37-2-4.47V4.5h-2v1.98A5.96 5.96 0 0 0 12 6c-.97 0-1.89.23-2.71.63L7.11 4.45 5.64 5.92l2.18 2.18A5.96 5.96 0 0 0 6 12c0 3.31 2.69 6 6 6s6-2.69 6-6zm-6 4c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="discord-message discord-system-message">
|
<div className="discord-message discord-system-message">
|
||||||
<div className="discord-system-icon">
|
<div className="discord-system-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
{getSystemIcon()}
|
||||||
<path d="M18.5 12c0-1.77-.77-3.37-2-4.47V4.5h-2v1.98A5.96 5.96 0 0 0 12 6c-.97 0-1.89.23-2.71.63L7.11 4.45 5.64 5.92l2.18 2.18A5.96 5.96 0 0 0 6 12c0 3.31 2.69 6 6 6s6-2.69 6-6zm-6 4c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="discord-system-content">
|
<span className="discord-system-content">
|
||||||
<span className="discord-username" style={displayAuthor?.color ? { color: displayAuthor.color } : undefined}>
|
<span className="discord-username" style={displayAuthor?.color ? { color: displayAuthor.color } : undefined}>
|
||||||
{displayAuthor?.displayName || displayAuthor?.username || 'Unknown'}
|
{displayAuthor?.displayName || displayAuthor?.username || 'Unknown'}
|
||||||
</span>
|
</span>
|
||||||
{' '}
|
{' '}
|
||||||
<span dangerouslySetInnerHTML={{ __html: parsedContent }} />
|
{systemText ? (
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: parseDiscordMarkdown(systemText, { usersMap, channelMap, emojiCache, rolesMap }) }} />
|
||||||
|
) : (
|
||||||
|
<span className="discord-system-unknown">(system message type {type})</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="discord-timestamp">{formatTimestamp(displayTimestamp)}</span>
|
<span className="discord-timestamp">{formatTimestamp(displayTimestamp)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1352,18 +1523,21 @@ const DiscordMessage = memo(function DiscordMessage({
|
|||||||
<div className="discord-stickers">
|
<div className="discord-stickers">
|
||||||
{stickers.map((sticker) => (
|
{stickers.map((sticker) => (
|
||||||
<div key={sticker.id} className="discord-sticker" title={sticker.name}>
|
<div key={sticker.id} className="discord-sticker" title={sticker.name}>
|
||||||
{sticker.formatType === 3 ? (
|
{sticker.formatType === 3 && sticker.lottieData ? (
|
||||||
// Lottie stickers - show placeholder or name
|
// Lottie stickers with cached data
|
||||||
<div className="discord-sticker-lottie">
|
<LottieSticker data={sticker.lottieData} name={sticker.name} />
|
||||||
<span>{sticker.name}</span>
|
) : sticker.url ? (
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<img
|
<img
|
||||||
src={sticker.url}
|
src={sticker.url}
|
||||||
alt={sticker.name}
|
alt={sticker.name}
|
||||||
className="discord-sticker-image"
|
className="discord-sticker-image"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
// Fallback for missing sticker data
|
||||||
|
<div className="discord-sticker-placeholder">
|
||||||
|
<span>{sticker.name}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -2811,6 +2985,18 @@ export default function DiscordLogs() {
|
|||||||
// Check if current channel is dds-archive
|
// Check if current channel is dds-archive
|
||||||
const isArchiveChannel = selectedChannel?.name === 'dds-archive';
|
const isArchiveChannel = selectedChannel?.name === 'dds-archive';
|
||||||
|
|
||||||
|
// Check if Havoc bot is in the channel's member list
|
||||||
|
const HAVOC_BOT_ID = '1219636064608583770';
|
||||||
|
const isHavocInChannel = useMemo(() => {
|
||||||
|
if (!members?.groups) return true; // Assume present if members not loaded
|
||||||
|
for (const group of members.groups) {
|
||||||
|
if (group.members?.some(m => m.id === HAVOC_BOT_ID)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [members]);
|
||||||
|
|
||||||
// Group messages by author and time window (5 minutes)
|
// Group messages by author and time window (5 minutes)
|
||||||
// Messages are in ASC order (oldest first, newest at bottom), so we group accordingly
|
// Messages are in ASC order (oldest first, newest at bottom), so we group accordingly
|
||||||
// For archive channel, parse messages to get real author/timestamp for grouping
|
// For archive channel, parse messages to get real author/timestamp for grouping
|
||||||
@@ -3184,6 +3370,15 @@ export default function DiscordLogs() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{/* Disclaimer when Havoc bot is not in channel */}
|
||||||
|
{!isHavocInChannel && !loadingMembers && (
|
||||||
|
<div className="discord-havoc-disclaimer">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
|
||||||
|
</svg>
|
||||||
|
<span>Havoc no longer has access to this channel. New activity may not be visible.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default function MediaRequestForm() {
|
|||||||
const [albumPlaybackLoadingId, setAlbumPlaybackLoadingId] = useState(null);
|
const [albumPlaybackLoadingId, setAlbumPlaybackLoadingId] = useState(null);
|
||||||
const [shuffleAlbums, setShuffleAlbums] = useState({});
|
const [shuffleAlbums, setShuffleAlbums] = useState({});
|
||||||
const [audioProgress, setAudioProgress] = useState({ current: 0, duration: 0 });
|
const [audioProgress, setAudioProgress] = useState({ current: 0, duration: 0 });
|
||||||
|
const [diskSpace, setDiskSpace] = useState(null);
|
||||||
|
|
||||||
const debounceTimeout = useRef(null);
|
const debounceTimeout = useRef(null);
|
||||||
const autoCompleteRef = useRef(null);
|
const autoCompleteRef = useRef(null);
|
||||||
@@ -64,6 +65,22 @@ export default function MediaRequestForm() {
|
|||||||
lastUrlRef.current = window.location.pathname + window.location.search + window.location.hash;
|
lastUrlRef.current = window.location.pathname + window.location.search + window.location.hash;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Fetch disk space on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDiskSpace = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/disk-space');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setDiskSpace(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch disk space:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchDiskSpace();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return undefined;
|
if (typeof window === "undefined") return undefined;
|
||||||
|
|
||||||
@@ -999,6 +1016,32 @@ export default function MediaRequestForm() {
|
|||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
<BreadcrumbNav currentPage="request" />
|
<BreadcrumbNav currentPage="request" />
|
||||||
|
|
||||||
|
{/* Disk Space Indicator - always visible */}
|
||||||
|
{diskSpace && (
|
||||||
|
<div className="mb-4 flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2 1 3 3 3h10c2 0 3-1 3-3V7c0-2-1-3-3-3H7c-2 0-3 1-3 3z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V5c0-1 .5-2 2-2h4c1.5 0 2 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
<span className={`font-medium ${
|
||||||
|
diskSpace.usedPercent > 90 ? 'text-red-500' :
|
||||||
|
diskSpace.usedPercent > 75 ? 'text-yellow-500' : 'text-green-600 dark:text-green-400'
|
||||||
|
}`}>{diskSpace.availableFormatted}</span> available
|
||||||
|
</span>
|
||||||
|
<div className="w-20 h-1.5 bg-neutral-200 dark:bg-neutral-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${diskSpace.usedPercent > 90 ? 'bg-red-500' :
|
||||||
|
diskSpace.usedPercent > 75 ? 'bg-yellow-500' : 'bg-green-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${diskSpace.usedPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-neutral-400">({diskSpace.usedPercent}% used)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight mb-2">New Request</h2>
|
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight mb-2">New Request</h2>
|
||||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6">Search for an artist to browse and select tracks for download.</p>
|
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6">Search for an artist to browse and select tracks for download.</p>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -1283,8 +1326,8 @@ export default function MediaRequestForm() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end mt-4">
|
||||||
<Button onClick={handleSubmitRequest} color="primary" className="mt-4" disabled={isSubmitting}>
|
<Button onClick={handleSubmitRequest} color="primary" disabled={isSubmitting}>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="animate-spin h-4 w-4 border-2 border-t-2 border-gray-200 border-t-primary rounded-full"></span>
|
<span className="animate-spin h-4 w-4 border-2 border-t-2 border-gray-200 border-t-primary rounded-full"></span>
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ async function performAuth(Astro) {
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
|
// Check if we even have a refresh token before attempting refresh
|
||||||
|
if (!cookieHeader.includes('refresh_token=')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Try refresh with timeout
|
// Try refresh with timeout
|
||||||
controller = new AbortController();
|
controller = new AbortController();
|
||||||
timeout = setTimeout(() => controller.abort(), 3000);
|
timeout = setTimeout(() => controller.abort(), 3000);
|
||||||
@@ -68,7 +73,12 @@ async function performAuth(Astro) {
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (!refreshRes.ok) {
|
if (!refreshRes.ok) {
|
||||||
console.error("Token refresh failed", refreshRes.status);
|
let errorDetail = '';
|
||||||
|
try {
|
||||||
|
const errorBody = await refreshRes.text();
|
||||||
|
errorDetail = ` - ${errorBody}`;
|
||||||
|
} catch {}
|
||||||
|
console.error(`[SSR] Token refresh failed ${refreshRes.status}${errorDetail}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,61 +27,147 @@ if (typeof globalThis.Headers !== 'undefined' && typeof globalThis.Headers.proto
|
|||||||
}
|
}
|
||||||
|
|
||||||
const API_URL = "https://api.codey.lol";
|
const API_URL = "https://api.codey.lol";
|
||||||
|
const AUTH_TIMEOUT_MS = 3000; // 3 second timeout for auth requests
|
||||||
|
|
||||||
|
// Deduplication for concurrent refresh requests (prevents race condition where
|
||||||
|
// multiple SSR requests try to refresh simultaneously, causing 401s after the
|
||||||
|
// first one rotates the refresh token)
|
||||||
|
let refreshPromise = null;
|
||||||
|
let lastRefreshResult = null;
|
||||||
|
let lastRefreshTime = 0;
|
||||||
|
const REFRESH_RESULT_TTL = 5000; // Cache successful refresh result for 5 seconds
|
||||||
|
|
||||||
// Auth check function (mirrors requireAuthHook logic but for middleware)
|
// Auth check function (mirrors requireAuthHook logic but for middleware)
|
||||||
async function checkAuth(request) {
|
async function checkAuth(request) {
|
||||||
try {
|
try {
|
||||||
const cookieHeader = request.headers.get("cookie") ?? "";
|
const cookieHeader = request.headers.get("cookie") ?? "";
|
||||||
if (import.meta.env.DEV) console.log(`[middleware:checkAuth] Cookie header present: ${!!cookieHeader}`);
|
|
||||||
|
|
||||||
let res = await fetch(`${API_URL}/auth/id`, {
|
// Add timeout to prevent hanging
|
||||||
headers: { Cookie: cookieHeader },
|
let controller = new AbortController;
|
||||||
credentials: "include",
|
let timeout = setTimeout(() => controller.abort(), AUTH_TIMEOUT_MS);
|
||||||
});
|
let res;
|
||||||
|
try {
|
||||||
if (import.meta.env.DEV) console.log(`[middleware:checkAuth] Initial auth/id status: ${res.status}`);
|
res = await fetch(`${API_URL}/auth/id`, {
|
||||||
|
|
||||||
if (res.status === 401) {
|
|
||||||
// Try refresh
|
|
||||||
const refreshRes = await fetch(`${API_URL}/auth/refresh`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { Cookie: cookieHeader },
|
headers: { Cookie: cookieHeader },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
console.error("[middleware] auth/id failed or timed out", err.name === 'AbortError' ? 'timeout' : err);
|
||||||
|
return { authenticated: false, user: null, cookies: null };
|
||||||
|
}
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (!refreshRes.ok) {
|
if (res.status === 401) {
|
||||||
|
// Check if we even have a refresh token before attempting refresh
|
||||||
|
if (!cookieHeader.includes('refresh_token=')) {
|
||||||
return { authenticated: false, user: null, cookies: null };
|
return { authenticated: false, user: null, cookies: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get refreshed cookies
|
// Check if we have a recent successful refresh result we can reuse
|
||||||
let setCookies = [];
|
const now = Date.now();
|
||||||
if (typeof refreshRes.headers.getSetCookie === 'function') {
|
if (lastRefreshResult && (now - lastRefreshTime) < REFRESH_RESULT_TTL) {
|
||||||
setCookies = refreshRes.headers.getSetCookie();
|
console.log(`[middleware] Reusing cached refresh result from ${now - lastRefreshTime}ms ago`);
|
||||||
} else {
|
return lastRefreshResult;
|
||||||
const setCookieHeader = refreshRes.headers.get("set-cookie");
|
}
|
||||||
if (setCookieHeader) {
|
|
||||||
setCookies = setCookieHeader.split(/,(?=\s*[a-zA-Z_][a-zA-Z0-9_]*=)/);
|
// Deduplicate concurrent refresh requests
|
||||||
|
if (refreshPromise) {
|
||||||
|
console.log(`[middleware] Waiting for in-flight refresh request`);
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a new refresh request
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
console.log(`[middleware] Starting token refresh...`);
|
||||||
|
let controller = new AbortController();
|
||||||
|
let timeout = setTimeout(() => controller.abort(), AUTH_TIMEOUT_MS);
|
||||||
|
let refreshRes;
|
||||||
|
try {
|
||||||
|
refreshRes = await fetch(`${API_URL}/auth/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Cookie: cookieHeader },
|
||||||
|
credentials: "include",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
console.error("[middleware] auth/refresh failed or timed out", err.name === 'AbortError' ? 'timeout' : err);
|
||||||
|
return { authenticated: false, user: null, cookies: null };
|
||||||
}
|
}
|
||||||
}
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (setCookies.length === 0) {
|
if (!refreshRes.ok) {
|
||||||
return { authenticated: false, user: null, cookies: null };
|
// Log the response body for debugging
|
||||||
}
|
let errorDetail = '';
|
||||||
|
try {
|
||||||
|
const errorBody = await refreshRes.text();
|
||||||
|
errorDetail = ` - ${errorBody}`;
|
||||||
|
} catch {}
|
||||||
|
console.error(`[middleware] Token refresh failed ${refreshRes.status}${errorDetail}`);
|
||||||
|
return { authenticated: false, user: null, cookies: null };
|
||||||
|
}
|
||||||
|
|
||||||
// Build new cookie header for retry
|
console.log(`[middleware] Token refresh succeeded`);
|
||||||
const newCookieHeader = setCookies.map(c => c.split(";")[0].trim()).join("; ");
|
|
||||||
|
|
||||||
res = await fetch(`${API_URL}/auth/id`, {
|
// Get refreshed cookies
|
||||||
headers: { Cookie: newCookieHeader },
|
let setCookies = [];
|
||||||
credentials: "include",
|
if (typeof refreshRes.headers.getSetCookie === 'function') {
|
||||||
|
setCookies = refreshRes.headers.getSetCookie();
|
||||||
|
} else {
|
||||||
|
const setCookieHeader = refreshRes.headers.get("set-cookie");
|
||||||
|
if (setCookieHeader) {
|
||||||
|
setCookies = setCookieHeader.split(/,(?=\s*[a-zA-Z_][a-zA-Z0-9_]*=)/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setCookies.length === 0) {
|
||||||
|
console.error("[middleware] No set-cookie headers in refresh response");
|
||||||
|
return { authenticated: false, user: null, cookies: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new cookie header for retry
|
||||||
|
const newCookieHeader = setCookies.map(c => c.split(";")[0].trim()).join("; ");
|
||||||
|
|
||||||
|
// Retry auth/id with new cookies and timeout
|
||||||
|
controller = new AbortController();
|
||||||
|
timeout = setTimeout(() => controller.abort(), AUTH_TIMEOUT_MS);
|
||||||
|
let retryRes;
|
||||||
|
try {
|
||||||
|
retryRes = await fetch(`${API_URL}/auth/id`, {
|
||||||
|
headers: { Cookie: newCookieHeader },
|
||||||
|
credentials: "include",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
console.error("[middleware] auth/id retry failed or timed out", err.name === 'AbortError' ? 'timeout' : err);
|
||||||
|
return { authenticated: false, user: null, cookies: null };
|
||||||
|
}
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!retryRes.ok) {
|
||||||
|
console.error(`[middleware] auth/id retry failed with status ${retryRes.status}`);
|
||||||
|
return { authenticated: false, user: null, cookies: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await retryRes.json();
|
||||||
|
return { authenticated: true, user, cookies: setCookies };
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Clear the promise when done and cache the result
|
||||||
|
refreshPromise.then(result => {
|
||||||
|
if (result.authenticated) {
|
||||||
|
lastRefreshResult = result;
|
||||||
|
lastRefreshTime = Date.now();
|
||||||
|
}
|
||||||
|
refreshPromise = null;
|
||||||
|
}).catch(() => {
|
||||||
|
refreshPromise = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
return refreshPromise;
|
||||||
return { authenticated: false, user: null, cookies: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await res.json();
|
|
||||||
return { authenticated: true, user, cookies: setCookies };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -604,14 +604,15 @@ export async function GET({ request }) {
|
|||||||
GROUP BY r.message_id, r.emoji_id, r.emoji_name, r.emoji_animated, e.cached_image_id
|
GROUP BY r.message_id, r.emoji_id, r.emoji_name, r.emoji_animated, e.cached_image_id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Fetch stickers for all messages (include cached_image_id for locally cached stickers)
|
// Fetch stickers for all messages (include cached_image_id and lottie_data for locally cached stickers)
|
||||||
const stickers = await sql`
|
const stickers = await sql`
|
||||||
SELECT
|
SELECT
|
||||||
ms.message_id,
|
ms.message_id,
|
||||||
s.sticker_id,
|
s.sticker_id,
|
||||||
s.name,
|
s.name,
|
||||||
s.format_type,
|
s.format_type,
|
||||||
s.cached_image_id
|
s.cached_image_id,
|
||||||
|
s.lottie_data
|
||||||
FROM message_stickers ms
|
FROM message_stickers ms
|
||||||
JOIN stickers s ON ms.sticker_id = s.sticker_id
|
JOIN stickers s ON ms.sticker_id = s.sticker_id
|
||||||
WHERE ms.message_id = ANY(${messageIds})
|
WHERE ms.message_id = ANY(${messageIds})
|
||||||
@@ -910,18 +911,19 @@ export async function GET({ request }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sticker format types: 1=PNG, 2=APNG, 3=Lottie, 4=GIF
|
// Sticker format types: 1=PNG, 2=APNG, 3=Lottie, 4=GIF
|
||||||
const stickerExtensions = { 1: 'png', 2: 'png', 3: 'json', 4: 'gif' };
|
const stickerExtensions = { 1: 'png', 2: 'png', 3: 'png', 4: 'gif' };
|
||||||
for (const sticker of stickers) {
|
for (const sticker of stickers) {
|
||||||
if (!stickersByMessage[sticker.message_id]) {
|
if (!stickersByMessage[sticker.message_id]) {
|
||||||
stickersByMessage[sticker.message_id] = [];
|
stickersByMessage[sticker.message_id] = [];
|
||||||
}
|
}
|
||||||
const ext = stickerExtensions[sticker.format_type] || 'png';
|
const ext = stickerExtensions[sticker.format_type] || 'png';
|
||||||
// Use cached sticker image if available, otherwise fall back to Discord CDN
|
// Use cached sticker image if available, otherwise fall back to Discord CDN
|
||||||
let stickerUrl;
|
let stickerUrl = null;
|
||||||
if (sticker.cached_image_id) {
|
if (sticker.cached_image_id) {
|
||||||
const sig = signImageId(sticker.cached_image_id);
|
const sig = signImageId(sticker.cached_image_id);
|
||||||
stickerUrl = `${baseUrl}/api/discord/cached-image?id=${sticker.cached_image_id}&sig=${sig}`;
|
stickerUrl = `${baseUrl}/api/discord/cached-image?id=${sticker.cached_image_id}&sig=${sig}`;
|
||||||
} else {
|
} else if (sticker.format_type !== 3) {
|
||||||
|
// Only use CDN fallback for non-Lottie stickers (Lottie will use lottie_data)
|
||||||
stickerUrl = `https://media.discordapp.net/stickers/${sticker.sticker_id}.${ext}?size=160`;
|
stickerUrl = `https://media.discordapp.net/stickers/${sticker.sticker_id}.${ext}?size=160`;
|
||||||
}
|
}
|
||||||
stickersByMessage[sticker.message_id].push({
|
stickersByMessage[sticker.message_id].push({
|
||||||
@@ -929,6 +931,7 @@ export async function GET({ request }) {
|
|||||||
name: sticker.name,
|
name: sticker.name,
|
||||||
formatType: sticker.format_type,
|
formatType: sticker.format_type,
|
||||||
url: stickerUrl,
|
url: stickerUrl,
|
||||||
|
lottieData: sticker.lottie_data || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
70
src/pages/api/disk-space.js
Normal file
70
src/pages/api/disk-space.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { requireApiAuth } from '../../utils/apiAuth.js';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
export async function GET({ request }) {
|
||||||
|
// Check authentication
|
||||||
|
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
// Check authorization - must have 'admin' or 'trip' role
|
||||||
|
const userRoles = user?.roles || [];
|
||||||
|
const hasAccess = userRoles.includes('admin') || userRoles.includes('trip');
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'Insufficient permissions. Requires admin or trip role.'
|
||||||
|
}), {
|
||||||
|
status: 403,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get disk space for root filesystem
|
||||||
|
const { stdout } = await execAsync("df -B1 / | tail -1 | awk '{print $2,$3,$4}'");
|
||||||
|
const [total, used, available] = stdout.trim().split(/\s+/).map(Number);
|
||||||
|
|
||||||
|
if (!total || !available) {
|
||||||
|
throw new Error('Failed to parse disk space');
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedPercent = Math.round((used / total) * 100);
|
||||||
|
|
||||||
|
const responseHeaders = { 'Content-Type': 'application/json' };
|
||||||
|
if (setCookieHeader) {
|
||||||
|
responseHeaders['Set-Cookie'] = setCookieHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
total,
|
||||||
|
used,
|
||||||
|
available,
|
||||||
|
usedPercent,
|
||||||
|
// Human-readable versions
|
||||||
|
totalFormatted: formatBytes(total),
|
||||||
|
usedFormatted: formatBytes(used),
|
||||||
|
availableFormatted: formatBytes(available),
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: responseHeaders,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting disk space:', err);
|
||||||
|
return new Response(JSON.stringify({ error: 'Failed to get disk space' }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user