Compare commits
6 Commits
de50889b2c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e18aa3f42c | |||
| 55e4c5ff0c | |||
| c3f0197115 | |||
| 6660b9ffd0 | |||
| ee33b86fe7 | |||
| d8d6c5ec21 |
BIN
public/images/req.png
Normal file
BIN
public/images/req.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 859 B |
86
public/styles/player.css
Normal file
86
public/styles/player.css
Normal file
@@ -0,0 +1,86 @@
|
||||
/* Universal box-sizing for consistency */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--lrc-text-color: #333; /* darker text */
|
||||
--lrc-bg-color: rgba(0, 0, 0, 0.05);
|
||||
--lrc-active-color: #000; /* bold black for active */
|
||||
--lrc-active-shadow: none; /* no glow in light mode */
|
||||
--lrc-hover-color: #005fcc; /* darker blue hover */
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--lrc-text-color: #ccc; /* original gray */
|
||||
--lrc-bg-color: rgba(255, 255, 255, 0.02);
|
||||
--lrc-active-color: #fff; /* bright white for active */
|
||||
--lrc-active-shadow: 0 0 4px rgba(212, 175, 55, 0.6); /* gold glow */
|
||||
--lrc-hover-color: #4fa2ff; /* original blue hover */
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 100vw;
|
||||
min-height: 100vh;
|
||||
/* background: linear-gradient(-45deg, #FFCDD2 50%, #B2EBF2 50%); */
|
||||
}
|
||||
|
||||
/* Container for the player and album cover */
|
||||
.music-container {
|
||||
width: 800px; /* fixed desktop width */
|
||||
max-width: 90vw; /* prevent overflow on smaller screens */
|
||||
height: auto !important;
|
||||
margin: 0 auto 120px auto; /* increased bottom margin */
|
||||
overflow-x: visible; /* allow horizontal overflow if needed */
|
||||
overflow-y: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 1rem;
|
||||
box-shadow: 1px 1px 5px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Album cover section */
|
||||
.album-cover {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 100%;
|
||||
max-width: 30%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.album-cover > img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
|
||||
.album-cover > img:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Player info and controls */
|
||||
.music-player {
|
||||
flex: 1 1 70%; /* Take remaining ~70% */
|
||||
max-width: 70%;
|
||||
width: auto;
|
||||
height: auto !important; /* Match container height */
|
||||
padding: 1em;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background: inherit;
|
||||
box-sizing: border-box;
|
||||
min-width: 0; /* Fix flex overflow */
|
||||
flex-shrink: 0; /* Prevent shrinking that hides content */
|
||||
}
|
||||
2557
src/assets/styles/DiscordLogs.css
Normal file
2557
src/assets/styles/DiscordLogs.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -101,3 +101,51 @@
|
||||
.meme-dialog-nav-next {
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Skeleton loader for images */
|
||||
.meme-skeleton {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(128, 128, 128, 0.1) 0%,
|
||||
rgba(128, 128, 128, 0.2) 50%,
|
||||
rgba(128, 128, 128, 0.1) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.meme-img-loading {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.meme-img {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* Mobile swipe hint */
|
||||
@media (max-width: 768px) {
|
||||
.meme-dialog-nav {
|
||||
opacity: 0.5;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.meme-dialog-body {
|
||||
touch-action: pan-y pinch-zoom;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,22 +4,23 @@
|
||||
|
||||
@theme {
|
||||
/* Font families */
|
||||
--font-sans: "Geist Sans", system-ui, sans-serif;
|
||||
--font-sans: "IBM Plex Sans", "Geist Sans", system-ui, sans-serif;
|
||||
--font-mono: "Geist Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: #47a3f3;
|
||||
color: #fefefe;
|
||||
background-color: #3b82f6;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Dark theme colors */
|
||||
[data-theme="dark"] {
|
||||
background-color: #121212;
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 360px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.prose {
|
||||
@@ -200,9 +201,24 @@ Custom
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
/* Page section - consistent spacing for all page content */
|
||||
.page-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: grid;
|
||||
align-items: end;
|
||||
padding: 2.5rem 0 2rem 0;
|
||||
margin-top: auto;
|
||||
padding-top: 3rem;
|
||||
text-align: center;
|
||||
font-size: 0.95rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .footer {
|
||||
border-top-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.header-text, .footer-text {
|
||||
@@ -235,8 +251,103 @@ Custom
|
||||
margin-left: 50%;
|
||||
}
|
||||
|
||||
#exclude-checkboxes {
|
||||
margin-left: 5.5%;
|
||||
/* Search button */
|
||||
.search-btn {
|
||||
padding: 0.625rem 1.5rem;
|
||||
background: linear-gradient(135deg, #171717 0%, #262626 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.search-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.search-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .search-btn {
|
||||
background: linear-gradient(135deg, #fafafa 0%, #e5e5e5 100%);
|
||||
color: #171717;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .search-btn:hover {
|
||||
box-shadow: 0 4px 16px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Exclude sources - toggle chips */
|
||||
.exclude-sources {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.exclude-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #737373;
|
||||
margin-right: 0.125rem;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .exclude-label {
|
||||
color: #525252;
|
||||
}
|
||||
|
||||
.exclude-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
color: #525252;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .exclude-chip {
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.exclude-chip:hover {
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
color: #dc2626;
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .exclude-chip:hover {
|
||||
border-color: rgba(248, 113, 113, 0.4);
|
||||
color: #f87171;
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
}
|
||||
|
||||
/* Active/excluded state */
|
||||
.exclude-chip--active {
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 1.5px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .exclude-chip--active {
|
||||
border-color: rgba(248, 113, 113, 0.5);
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
#lyric-search-input {
|
||||
@@ -247,7 +358,7 @@ Custom
|
||||
.lyric-search-input-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.lyric-search-input-wrapper .p-autocomplete {
|
||||
@@ -255,19 +366,46 @@ Custom
|
||||
}
|
||||
|
||||
.lyric-search-input-wrapper .p-autocomplete-input {
|
||||
padding-right: 2.5rem;
|
||||
width: 100%;
|
||||
padding: 0.875rem 2.75rem 0.875rem 1.125rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
font-size: 1rem;
|
||||
background: white;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.lyric-search-input-wrapper .p-autocomplete-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .lyric-search-input-wrapper .p-autocomplete-input {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .lyric-search-input-wrapper .p-autocomplete-input:focus {
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.15);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .lyric-search-input-wrapper .p-autocomplete-input::placeholder {
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.input-status-icon {
|
||||
position: absolute;
|
||||
right: 0.85rem;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
transform: none;
|
||||
right: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
transition: opacity 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -282,10 +420,29 @@ Custom
|
||||
}
|
||||
|
||||
.lyrics-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 8px 24px rgba(0,0,0,0.04);
|
||||
padding: 1.5rem;
|
||||
transition: background 0.3s;
|
||||
transition: background 0.3s, box-shadow 0.3s;
|
||||
background: white;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .lyrics-card {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2), 0 8px 24px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.lyrics-card-animate {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
|
||||
}
|
||||
|
||||
.lyrics-card-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.lyrics-toolbar {
|
||||
@@ -294,11 +451,18 @@ Custom
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .lyrics-toolbar {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.lyrics-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -306,22 +470,27 @@ Custom
|
||||
.lyrics-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.text-size-buttons {
|
||||
display: flex;
|
||||
border: 1px solid rgba(79, 70, 229, 0.25);
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: rgba(79, 70, 229, 0.06);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .text-size-buttons {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.text-size-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
padding: 0.15rem 0.5rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
@@ -332,13 +501,17 @@ Custom
|
||||
}
|
||||
|
||||
.text-size-btn.active {
|
||||
background: rgba(79, 70, 229, 0.15);
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .text-size-btn.active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.lyrics-content {
|
||||
line-height: 2.0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: 'IBM Plex Sans', 'Inter', sans-serif;
|
||||
font-size: 1rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -348,6 +521,32 @@ Custom
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.lyrics-verse {
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0.25rem -0.75rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.lyrics-verse:hover {
|
||||
background-color: rgba(79, 70, 229, 0.06);
|
||||
}
|
||||
|
||||
.lyrics-verse-highlighted {
|
||||
background-color: rgba(79, 70, 229, 0.15);
|
||||
box-shadow: inset 3px 0 0 rgba(79, 70, 229, 0.6);
|
||||
}
|
||||
|
||||
.lyrics-card-dark .lyrics-verse:hover {
|
||||
background-color: rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.lyrics-card-dark .lyrics-verse-highlighted {
|
||||
background-color: rgba(139, 92, 246, 0.2);
|
||||
box-shadow: inset 3px 0 0 rgba(139, 92, 246, 0.7);
|
||||
}
|
||||
|
||||
.lyrics-action-button {
|
||||
color: inherit;
|
||||
border: 1px solid transparent;
|
||||
@@ -367,6 +566,10 @@ Custom
|
||||
background-color: oklch(from rgba(255, 255, 255, 2.0) calc(l - 0.02) c h);
|
||||
}
|
||||
|
||||
.discord-logs-container {
|
||||
padding-bottom: 25px;
|
||||
}
|
||||
|
||||
.random-msg {
|
||||
padding-top: 10px;
|
||||
max-width: 100%;
|
||||
@@ -380,10 +583,52 @@ Custom
|
||||
padding-bottom: 3%;
|
||||
}
|
||||
|
||||
/* PrimeReact AutoComplete Panel - Global Styling */
|
||||
.p-autocomplete-panel {
|
||||
background: white;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
|
||||
overflow: hidden;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .p-autocomplete-panel {
|
||||
background: #1a1a1a;
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.p-autocomplete-items {
|
||||
max-height: 200px !important;
|
||||
overflow-y: auto !important;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.p-autocomplete-item {
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: 8px;
|
||||
margin: 0.125rem 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.p-autocomplete-item:hover,
|
||||
.p-autocomplete-item.p-highlight {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .p-autocomplete-item {
|
||||
color: #e5e5e5;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .p-autocomplete-item:hover,
|
||||
[data-theme="dark"] .p-autocomplete-item.p-highlight {
|
||||
background: rgba(96, 165, 250, 0.15);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.p-autocomplete-input {
|
||||
@@ -392,6 +637,7 @@ Custom
|
||||
border: 1px solid #ccc;
|
||||
transition: border 0.2s;
|
||||
}
|
||||
|
||||
.p-autocomplete-input:focus {
|
||||
border-color: #4f46e5;
|
||||
outline: none;
|
||||
@@ -506,18 +752,52 @@ Custom
|
||||
/*
|
||||
Toastify customizations
|
||||
*/
|
||||
.Toastify__toast--error {
|
||||
background-color: rgba(255, 0, 0, 0.5) !important;
|
||||
color: inherit !important;
|
||||
.Toastify__toast {
|
||||
border-radius: 12px !important;
|
||||
backdrop-filter: blur(12px) !important;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3) !important;
|
||||
font-family: 'IBM Plex Sans', sans-serif !important;
|
||||
font-size: 0.9rem !important;
|
||||
background: rgba(30, 30, 30, 0.95) !important;
|
||||
color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
.Toastify__toast--error {
|
||||
background: rgba(30, 30, 30, 0.95) !important;
|
||||
border-left: 4px solid #ef4444 !important;
|
||||
color: #fca5a5 !important;
|
||||
}
|
||||
|
||||
.Toastify__toast--info {
|
||||
background-color: rgba(217, 242, 255, 0.8) !important;
|
||||
color: #000 !important;
|
||||
background: rgba(30, 30, 30, 0.95) !important;
|
||||
border-left: 4px solid #3b82f6 !important;
|
||||
color: #93c5fd !important;
|
||||
}
|
||||
|
||||
.Toastify__toast--success {
|
||||
background-color: rgba(46, 186, 106, 0.8) !important;
|
||||
color: inherit !important;
|
||||
background: rgba(30, 30, 30, 0.95) !important;
|
||||
border-left: 4px solid #22c55e !important;
|
||||
color: #86efac !important;
|
||||
}
|
||||
|
||||
.Toastify__toast--warning {
|
||||
background: rgba(30, 30, 30, 0.95) !important;
|
||||
border-left: 4px solid #f59e0b !important;
|
||||
color: #fcd34d !important;
|
||||
}
|
||||
|
||||
.Toastify__close-button {
|
||||
color: #a3a3a3 !important;
|
||||
opacity: 0.7 !important;
|
||||
}
|
||||
|
||||
.Toastify__close-button:hover {
|
||||
opacity: 1 !important;
|
||||
color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
.Toastify__progress-bar {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
.Toastify__toast--success > .Toastify__toast-icon svg {
|
||||
@@ -527,3 +807,8 @@ Toastify customizations
|
||||
.Toastify__toast--success > .Toastify__toast-icon::after {
|
||||
content: "🦄" !important;
|
||||
}
|
||||
|
||||
/* Light mode - keep dark toasts */
|
||||
[data-theme="light"] .Toastify__toast {
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
|
||||
@@ -79,22 +79,34 @@ nav {
|
||||
.nav-user-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.85rem;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
font-size: 0.82rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
background: linear-gradient(120deg, rgba(255, 255, 255, 0.9), rgba(226, 232, 240, 0.85));
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
color: #374151;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(241, 245, 249, 0.9));
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-user-inline:hover {
|
||||
border-color: rgba(148, 163, 184, 0.5);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .nav-user-inline {
|
||||
color: #f1f5f9;
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
background: linear-gradient(120deg, rgba(15, 23, 42, 0.85), rgba(30, 41, 59, 0.7));
|
||||
box-shadow: 0 14px 35px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
color: #e2e8f0;
|
||||
border-color: rgba(71, 85, 105, 0.4);
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.9), rgba(15, 23, 42, 0.85));
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .nav-user-inline:hover {
|
||||
border-color: rgba(100, 116, 139, 0.5);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.nav-user-inline__icon {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import React, { Suspense, lazy, useState, useMemo, useEffect } from 'react';
|
||||
import Memes from './Memes.jsx';
|
||||
import Lighting from './Lighting.jsx';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -14,13 +14,69 @@ const LoginPage = lazy(() => import('./Login.jsx'));
|
||||
const LyricSearch = lazy(() => import('./LyricSearch'));
|
||||
const MediaRequestForm = lazy(() => import('./TRip/MediaRequestForm.jsx'));
|
||||
const RequestManagement = lazy(() => import('./TRip/RequestManagement.jsx'));
|
||||
const Player = lazy(() => import('./AudioPlayer.jsx'));
|
||||
const DiscordLogs = lazy(() => import('./DiscordLogs.jsx'));
|
||||
// NOTE: Player is intentionally NOT imported at module initialization.
|
||||
// We create the lazy import inside the component at render-time only when
|
||||
// we are on the main site and the Player island should be rendered. This
|
||||
// prevents bundling the player island into pages that are explicitly
|
||||
// identified as subsites.
|
||||
const ReqForm = lazy(() => import('./req/ReqForm.jsx'));
|
||||
|
||||
export default function Root({ child, user = undefined, ...props }) {
|
||||
window.toast = toast;
|
||||
const theme = document.documentElement.getAttribute("data-theme")
|
||||
const loggedIn = props.loggedIn ?? Boolean(user);
|
||||
usePrimeReactThemeSwitcher(theme);
|
||||
// Avoid adding the Player island for subsite requests. We expose a
|
||||
// runtime flag `window.__IS_SUBSITE` from the server layout so pages
|
||||
// don't need to pass guards.
|
||||
const isSubsite = typeof document !== 'undefined' && document.documentElement.getAttribute('data-subsite') === 'true';
|
||||
// Log when the active child changes (DEV only)
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (typeof console !== 'undefined' && typeof document !== 'undefined' && import.meta.env.DEV) {
|
||||
console.debug(`[AppLayout] child=${String(child)}, data-subsite=${document.documentElement.getAttribute('data-subsite')}`);
|
||||
}
|
||||
} catch (e) {
|
||||
// no-op
|
||||
}
|
||||
}, [child]);
|
||||
|
||||
// Only initialize the lazy player when this is NOT a subsite and the
|
||||
// active child is the Player island. Placing the lazy() call here
|
||||
// avoids creating a static dependency at module load time.
|
||||
// Create the lazy component only when we actually need it. Using
|
||||
// `useMemo` ensures we don't re-create the lazy factory on every render
|
||||
// which would create a new component identity and cause mount/unmount
|
||||
// loops and repeated log messages.
|
||||
const wantPlayer = !isSubsite && child === "Player";
|
||||
|
||||
// Use dynamic import+state on the client to avoid React.lazy identity
|
||||
// churn and to surface any import-time errors. Since Root is used via
|
||||
// client:only, this code runs in the browser and can safely import.
|
||||
const [PlayerComp, setPlayerComp] = useState(null);
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
if (wantPlayer) {
|
||||
if (import.meta.env.DEV) { try { console.debug('[AppLayout] dynamic-import: requesting AudioPlayer'); } catch (e) { } }
|
||||
import('./AudioPlayer.jsx')
|
||||
.then((mod) => {
|
||||
if (!mounted) return;
|
||||
// set the component factory
|
||||
setPlayerComp(() => mod.default ?? null);
|
||||
if (import.meta.env.DEV) { try { console.debug('[AppLayout] AudioPlayer import succeeded'); } catch (e) { } }
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[AppLayout] AudioPlayer import failed', err);
|
||||
if (mounted) setPlayerComp(() => null);
|
||||
});
|
||||
} else {
|
||||
// unload if we no longer want the player
|
||||
setPlayerComp(() => null);
|
||||
}
|
||||
return () => { mounted = false; };
|
||||
}, [wantPlayer]);
|
||||
|
||||
return (
|
||||
<PrimeReactProvider>
|
||||
<CustomToastContainer
|
||||
@@ -37,10 +93,20 @@ export default function Root({ child, user = undefined, ...props }) {
|
||||
</Alert> */}
|
||||
{child == "LoginPage" && (<LoginPage {...props} loggedIn={loggedIn} />)}
|
||||
{child == "LyricSearch" && (<LyricSearch {...props} client:only="react" />)}
|
||||
{child == "Player" && (<Player client:only="react" user={user} />)}
|
||||
{child == "Player" && !isSubsite && PlayerComp && (
|
||||
<Suspense fallback={null}>
|
||||
<PlayerComp client:only="react" user={user} />
|
||||
</Suspense>
|
||||
)}
|
||||
{child == "Memes" && <Memes client:only="react" />}
|
||||
{child == "DiscordLogs" && (
|
||||
<Suspense fallback={<div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>}>
|
||||
<DiscordLogs client:only="react" />
|
||||
</Suspense>
|
||||
)}
|
||||
{child == "qs2.MediaRequestForm" && <MediaRequestForm client:only="react" />}
|
||||
{child == "qs2.RequestManagement" && <RequestManagement client:only="react" />}
|
||||
{child == "ReqForm" && <ReqForm client:only="react" />}
|
||||
{child == "Lighting" && <Lighting key={window.location.pathname + Math.random()} client:only="react" />}
|
||||
</JoyUIRootIsland>
|
||||
</PrimeReactProvider>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, Suspense, lazy, useMemo, useCallback } from "react";
|
||||
import "@styles/player.css";
|
||||
import { metaData } from "../config";
|
||||
import Play from "@mui/icons-material/PlayArrow";
|
||||
import Pause from "@mui/icons-material/Pause";
|
||||
@@ -748,7 +749,7 @@ export default function Player({ user }) {
|
||||
|
||||
<div className="music-time flex justify-between items-center mt-4">
|
||||
<p className="music-time__current text-sm">{formatTime(elapsedTime)}</p>
|
||||
<p className="music-time__last text-sm">{formatTime(trackDuration - elapsedTime)}</p>
|
||||
<p className="music-time__last text-sm">-{formatTime(trackDuration - elapsedTime)}</p>
|
||||
</div>
|
||||
|
||||
<div className="progress-bar-container w-full h-2 rounded bg-neutral-300 dark:bg-neutral-700 overflow-hidden">
|
||||
|
||||
@@ -3,6 +3,9 @@ interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
isWhitelabel?: boolean;
|
||||
whitelabel?: any;
|
||||
subsite?: any;
|
||||
}
|
||||
|
||||
import { metaData } from "../config";
|
||||
@@ -11,22 +14,87 @@ import { JoyUIRootIsland } from "./Components"
|
||||
import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr";
|
||||
import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher";
|
||||
|
||||
const { title, description, image } = Astro.props;
|
||||
const { title, description, image, isWhitelabel, whitelabel, subsite } = Astro.props;
|
||||
|
||||
const { url } = Astro;
|
||||
|
||||
const shareTitle = title ? `${title} | ${metaData.title}` : metaData.shareTitle ?? metaData.title;
|
||||
const shareDescription = description ?? metaData.shareDescription ?? metaData.description;
|
||||
const canonicalUrl = url?.href ?? metaData.baseUrl;
|
||||
const shareImage = new URL(image ?? metaData.ogImage, metaData.baseUrl).toString();
|
||||
const shareImageAlt = metaData.shareImageAlt ?? metaData.shareTitle ?? metaData.title;
|
||||
const trimmedTitle = title?.trim();
|
||||
const seoTitle = trimmedTitle || metaData.title;
|
||||
// If a whitelabel/subsite override exists, prefer its shareTitle/shareDescription/ogImage/favicon
|
||||
const subsiteMeta = whitelabel ?? {};
|
||||
const shareTitle = isWhitelabel
|
||||
? (trimmedTitle || (subsiteMeta.shareTitle ?? metaData.shareTitle ?? metaData.title))
|
||||
: (trimmedTitle ? `${trimmedTitle} | ${metaData.title}` : (metaData.shareTitle ?? metaData.title));
|
||||
const seoTitleTemplate = isWhitelabel ? "%s" : (trimmedTitle ? `%s | ${metaData.title}` : "%s");
|
||||
const shareDescription = isWhitelabel
|
||||
? (trimmedTitle || (subsiteMeta.shareDescription ?? metaData.shareDescription ?? metaData.description))
|
||||
: (description ?? metaData.shareDescription ?? metaData.description);
|
||||
// Compute canonical URL with these priorities:
|
||||
// 1. If the whitelabel/subsite provides a baseUrl, use that as the host for the canonical URL
|
||||
// 2. If a subsite was detected and the request host matches the subsite host, canonical uses that host
|
||||
// 3. If the request is for a path-based subsite (host is main site), prefer metaData.baseUrl so canonical remains on main site
|
||||
// 4. Fallback to the request URL or metaData.baseUrl
|
||||
const currentHost = (Astro.request?.headers?.get('host') || '').split(':')[0];
|
||||
let canonicalBase: string | null = null;
|
||||
if (subsiteMeta?.baseUrl) {
|
||||
canonicalBase = subsiteMeta.baseUrl;
|
||||
} else if (subsite?.host) {
|
||||
// normalize hosts for comparison
|
||||
const requestedHost = (currentHost || '').toLowerCase();
|
||||
const subsiteHost = String(subsite.host || '').toLowerCase();
|
||||
if (requestedHost && requestedHost === subsiteHost) {
|
||||
canonicalBase = `https://${subsite.host}`;
|
||||
} else {
|
||||
// keep canonical on the main configured base (path-based subsites should remain under metaData.baseUrl)
|
||||
canonicalBase = metaData.baseUrl;
|
||||
}
|
||||
}
|
||||
// Decide whether canonical should be the site-root (e.g. https://req.boatson.boats/)
|
||||
// or include a path. Rules:
|
||||
// - If canonicalBase comes from a whitelabel/baseUrl or request host matches subsite host -> prefer site-root.
|
||||
// - Otherwise (path-based subsites), include the pathname/search so canonical remains under the main site path.
|
||||
const isHostMatchedSubsite = Boolean(subsite?.host && currentHost && currentHost.toLowerCase() === String(subsite.host).toLowerCase());
|
||||
const isSubsiteRootCanonical = Boolean(subsiteMeta?.baseUrl || isHostMatchedSubsite);
|
||||
let canonicalUrl: string;
|
||||
if (canonicalBase) {
|
||||
if (isSubsiteRootCanonical) {
|
||||
// ensure canonicalBase ends with a single '/'
|
||||
canonicalUrl = canonicalBase.endsWith('/') ? canonicalBase : `${canonicalBase}/`;
|
||||
} else {
|
||||
canonicalUrl = new URL((url?.pathname ?? '') + (url?.search ?? ''), canonicalBase).toString();
|
||||
}
|
||||
} else {
|
||||
canonicalUrl = url?.href ?? metaData.baseUrl;
|
||||
}
|
||||
// Prefer the whitelabel/subsite ogImage when this page is for a whitelabel site.
|
||||
// Otherwise fall back to an explicit image prop or the global ogImage.
|
||||
const resolvedOgImage = (isWhitelabel && subsiteMeta.ogImage) ? subsiteMeta.ogImage : (image ?? metaData.ogImage);
|
||||
|
||||
// Keep relative/site-root paths as-is (e.g. '/images/req.png') and don't force
|
||||
// an absolute URL using metaData.baseUrl. Only keep absolute (http/https)
|
||||
// URLs if the value is already absolute.
|
||||
function keepRelativeOrAbsolute(val) {
|
||||
if (!val) return val;
|
||||
if (/^https?:\/\//i.test(val)) return val; // already absolute
|
||||
// Normalize to site-root-relative so local testing always resolves under '/'
|
||||
return val.startsWith('/') ? val : `/${val}`;
|
||||
}
|
||||
|
||||
const shareImage = keepRelativeOrAbsolute(resolvedOgImage);
|
||||
const shareImageAlt = subsiteMeta.shareImageAlt ?? metaData.shareImageAlt ?? metaData.shareTitle ?? metaData.title;
|
||||
|
||||
// Build icon links: prefer subsite icons when present. If a subsite provides a single
|
||||
// `favicon` but no `icons` array, do NOT append the global icons to avoid duplicates.
|
||||
const primaryIconHref = keepRelativeOrAbsolute(subsiteMeta.favicon ?? metaData.favicon);
|
||||
// Ensure extraIcons are used only when subsite doesn't provide a single favicon (prevents duplicates)
|
||||
const extraIcons = subsiteMeta.icons ? subsiteMeta.icons : (subsiteMeta.favicon ? [] : (metaData.icons ?? []));
|
||||
|
||||
---
|
||||
|
||||
<SEO
|
||||
title={shareTitle}
|
||||
titleTemplate=`%s | ${metaData.title}`
|
||||
title={seoTitle}
|
||||
titleTemplate={seoTitleTemplate}
|
||||
titleDefault={metaData.title}
|
||||
canonical={canonicalUrl}
|
||||
description={shareDescription}
|
||||
charset="UTF-8"
|
||||
openGraph={{
|
||||
@@ -38,14 +106,16 @@ const shareImageAlt = metaData.shareImageAlt ?? metaData.shareTitle ?? metaData.
|
||||
},
|
||||
optional: {
|
||||
description: shareDescription,
|
||||
siteName: metaData.name,
|
||||
siteName: subsiteMeta.siteName ?? metaData.name,
|
||||
locale: "en_US",
|
||||
},
|
||||
}}
|
||||
extend={{
|
||||
extend={{
|
||||
link: [
|
||||
{ rel: "icon", href: "https://codey.lol/images/favicon.png" },
|
||||
{ rel: "canonical", href: canonicalUrl },
|
||||
// choose subsite favicon if provided, else global config. Allow absolute or relative paths
|
||||
{ rel: 'icon', href: primaryIconHref },
|
||||
// additional icon links from config if present
|
||||
...extraIcons,
|
||||
],
|
||||
meta: [
|
||||
{ property: "og:image:alt", content: shareImageAlt },
|
||||
|
||||
3153
src/components/DiscordLogs.jsx
Normal file
3153
src/components/DiscordLogs.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,21 @@
|
||||
---
|
||||
import { metaData, ENVIRONMENT } from "../config";
|
||||
import { metaData, ENVIRONMENT, WHITELABELS } from "../config";
|
||||
import RandomMsg from "../components/RandomMsg";
|
||||
import { buildTime, buildNumber } from '../utils/buildTime.js';
|
||||
|
||||
const YEAR = new Date().getFullYear();
|
||||
|
||||
|
||||
|
||||
const hostHeader = Astro.request?.headers?.get('host') || '';
|
||||
const host = hostHeader.split(':')[0];
|
||||
import { getSubsiteByHost } from '../utils/subsites.js';
|
||||
import { getSubsiteByPath } from '../utils/subsites.js';
|
||||
const detected = getSubsiteByHost(host) ?? getSubsiteByPath(Astro.url.pathname) ?? null;
|
||||
const isReq = detected?.short === 'req';
|
||||
const whitelabel = WHITELABELS[host] ?? (isReq ? WHITELABELS[detected.host] : null);
|
||||
---
|
||||
|
||||
<div class="footer">
|
||||
<RandomMsg client:only="react" />
|
||||
{!whitelabel && <RandomMsg client:only="react" />}
|
||||
<div style="margin-top: 15px; bottom: 0%">
|
||||
<small>Build# {buildNumber}
|
||||
<br>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useRef, Suspense, lazy } from 'react';
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { API_URL } from '../config.js';
|
||||
import { authFetch } from '../utils/authFetch.js';
|
||||
import Wheel from '@uiw/react-color-wheel';
|
||||
@@ -8,6 +8,8 @@ export default function Lighting() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const debounceRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
authFetch(`${API_URL}/lighting/state`)
|
||||
@@ -26,38 +28,44 @@ export default function Lighting() {
|
||||
setState({ power: '', red: 0, blue: 0, green: 0, brightness: 100 });
|
||||
}, []);
|
||||
|
||||
const handleColorChange = (color) => {
|
||||
console.log('Handle color change:', color);
|
||||
const { r, g, b } = color.rgb;
|
||||
updateLighting({
|
||||
...state,
|
||||
red: r,
|
||||
green: g,
|
||||
blue: b,
|
||||
});
|
||||
};
|
||||
// Cleanup debounce on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const debounceRef = useRef();
|
||||
|
||||
const updateLighting = (newState) => {
|
||||
// Consolidated debounced update function
|
||||
const updateLighting = useCallback((newState) => {
|
||||
setState(newState);
|
||||
setPending(true);
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
|
||||
// Clear any pending timeout
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout for API call
|
||||
// Set new timeout for API call (250ms for smoother drag experience)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
authFetch(`${API_URL}/lighting/state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newState),
|
||||
})
|
||||
.then(() => setSuccess(true))
|
||||
.catch(() => setError('Failed to update lighting state'));
|
||||
}, 100); // 100ms debounce for 25 req/2s rate limit
|
||||
};
|
||||
.then(() => {
|
||||
setSuccess(true);
|
||||
setPending(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setError('Failed to update lighting state');
|
||||
setPending(false);
|
||||
});
|
||||
}, 250);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
@@ -82,7 +90,7 @@ export default function Lighting() {
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-[60vh] flex justify-center items-center mt-12">
|
||||
<div className="w-full min-h-[60vh] flex justify-center items-center mt-12 mb-12">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="max-w-md w-full p-8 rounded-xl shadow bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 flex flex-col items-center justify-center"
|
||||
@@ -128,12 +136,14 @@ export default function Lighting() {
|
||||
s / 100, // saturation: 0-100 -> 0-1
|
||||
(v ?? 100) / 100 // value: 0-100 -> 0-1, default to 1 if undefined
|
||||
);
|
||||
console.log('Converting color:', color.hsva, 'to RGB:', rgb);
|
||||
if (import.meta.env.DEV) console.debug('Converting color:', color.hsva, 'to RGB:', rgb);
|
||||
// Auto power on when changing color
|
||||
updateLighting({
|
||||
...state,
|
||||
red: rgb.red,
|
||||
green: rgb.green,
|
||||
blue: rgb.blue
|
||||
blue: rgb.blue,
|
||||
power: state.power === 'off' ? 'on' : state.power
|
||||
});
|
||||
}}
|
||||
width={180}
|
||||
@@ -151,20 +161,18 @@ export default function Lighting() {
|
||||
value={state.brightness}
|
||||
onChange={e => {
|
||||
const newValue = Number(e.target.value);
|
||||
const newState = {
|
||||
// Auto power off at 0, auto power on when > 0
|
||||
let newPower = state.power;
|
||||
if (newValue === 0) {
|
||||
newPower = 'off';
|
||||
} else if (state.power === 'off' && newValue > 0) {
|
||||
newPower = 'on';
|
||||
}
|
||||
updateLighting({
|
||||
...state,
|
||||
brightness: newValue,
|
||||
power: newValue === 0 ? 'off' : state.power
|
||||
};
|
||||
setState(newState);
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
updateLighting(newState);
|
||||
}, 100); // 100ms debounce for 25 req/2s rate limit
|
||||
power: newPower
|
||||
});
|
||||
}}
|
||||
className="w-full max-w-xs accent-yellow-500"
|
||||
/>
|
||||
@@ -186,7 +194,8 @@ export default function Lighting() {
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-4 text-red-500 text-center font-bold animate-pulse">{error}</div>}
|
||||
{success && <div className="mb-4 text-green-600 text-center font-bold animate-bounce">Updated!</div>}
|
||||
{success && !pending && <div className="mb-4 text-green-600 text-center font-bold">Updated!</div>}
|
||||
{pending && <div className="mb-4 text-indigo-400 text-center text-sm">Syncing...</div>}
|
||||
{loading && <div className="mb-4 text-indigo-500 text-center font-bold animate-pulse">Loading...</div>}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function LoginPage({ loggedIn = false }) {
|
||||
<div className="max-w-md w-full bg-white dark:bg-[#1E1E1E] rounded-2xl shadow-xl px-10 py-8 text-center">
|
||||
<img className="logo-auth mx-auto mb-4" src="/images/zim.png" alt="Logo" />
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-4">You're already logged in</h2>
|
||||
<p className="text-sm text-gray-800 dark:text-gray-300 mb-4">You do not have permission to access this resource.
|
||||
<p className="text-sm text-gray-800 dark:text-gray-300 mb-4">But you do not have permission to access this resource.
|
||||
</p>
|
||||
<p className="text-xs italic text-gray-800 dark:text-gray-300 mb-4">
|
||||
If you feel you have received this message in error, scream at codey.
|
||||
|
||||
@@ -8,37 +8,41 @@ import React, {
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { toast } from 'react-toastify';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import Box from '@mui/joy/Box';
|
||||
import Button from "@mui/joy/Button";
|
||||
import IconButton from "@mui/joy/IconButton";
|
||||
import Checkbox from "@mui/joy/Checkbox";
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
|
||||
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import RemoveRoundedIcon from '@mui/icons-material/RemoveRounded';
|
||||
import { AutoComplete } from 'primereact/autocomplete';
|
||||
import { AutoComplete } from 'primereact/autocomplete/autocomplete.esm.js';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
// Sanitize HTML from external sources to prevent XSS
|
||||
const sanitizeHtml = (html) => {
|
||||
if (!html) return '';
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ['br', 'p', 'b', 'i', 'em', 'strong', 'span', 'div'],
|
||||
ALLOWED_ATTR: ['class'],
|
||||
});
|
||||
};
|
||||
|
||||
export default function LyricSearch() {
|
||||
const [showLyrics, setShowLyrics] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="lyric-search">
|
||||
<h2 className="title">
|
||||
<span>Lyric Search</span>
|
||||
</h2>
|
||||
<div className="card-text my-4">
|
||||
<label htmlFor="lyric-search-input">Search:</label>
|
||||
<LyricSearchInputField
|
||||
id="lyric-search-input"
|
||||
placeholder="Artist - Song"
|
||||
setShowLyrics={setShowLyrics}
|
||||
/>
|
||||
<div id="spinner" className="hidden">
|
||||
<CircularProgress variant="plain" color="primary" size="md" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-8 text-neutral-900 dark:text-white tracking-tight">
|
||||
Lyric Search
|
||||
</h1>
|
||||
<LyricSearchInputField
|
||||
id="lyric-search-input"
|
||||
placeholder="Artist - Song"
|
||||
setShowLyrics={setShowLyrics}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +57,10 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
const [lyricsResult, setLyricsResult] = useState(null);
|
||||
const [textSize, setTextSize] = useState("normal");
|
||||
const [inputStatus, setInputStatus] = useState("hint");
|
||||
const [youtubeVideo, setYoutubeVideo] = useState(null); // { video_id, title, channel, duration }
|
||||
const [youtubeLoading, setYoutubeLoading] = useState(false);
|
||||
const [highlightedVerse, setHighlightedVerse] = useState(null);
|
||||
const [isLyricsVisible, setIsLyricsVisible] = useState(false);
|
||||
const searchToastRef = useRef(null);
|
||||
const autoCompleteRef = useRef(null);
|
||||
const autoCompleteInputRef = useRef(null);
|
||||
@@ -122,6 +130,39 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
setSuggestions(json);
|
||||
}, []);
|
||||
|
||||
// Fetch YouTube video for the song
|
||||
const fetchYouTubeVideo = useCallback(async (artist, song) => {
|
||||
setYoutubeLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/yt/search`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ t: `${artist} - ${song}` }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setYoutubeVideo(null);
|
||||
setYoutubeLoading(false);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data?.video_id) {
|
||||
setYoutubeVideo({
|
||||
video_id: data.video_id,
|
||||
title: data.extras?.title || `${artist} - ${song}`,
|
||||
channel: data.extras?.channel || '',
|
||||
duration: data.extras?.duration || '',
|
||||
thumbnail: data.extras?.thumbnails?.[0] || null,
|
||||
});
|
||||
} else {
|
||||
setYoutubeVideo(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('YouTube search failed:', err);
|
||||
setYoutubeVideo(null);
|
||||
}
|
||||
setYoutubeLoading(false);
|
||||
}, []);
|
||||
|
||||
// Toggle exclusion state for checkboxes
|
||||
const toggleExclusion = (source) => {
|
||||
const lower = source.toLowerCase();
|
||||
@@ -194,10 +235,34 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
evaluateSearchValue(value);
|
||||
}, [value, evaluateSearchValue]);
|
||||
|
||||
const handleSearch = async (searchValue = value) => {
|
||||
if (autoCompleteRef.current) {
|
||||
autoCompleteRef.current.hide();
|
||||
// Robustly hide autocomplete panel
|
||||
const hideAutocompletePanel = useCallback(() => {
|
||||
// Method 1: Use PrimeReact API
|
||||
if (autoCompleteRef.current?.hide) {
|
||||
try {
|
||||
autoCompleteRef.current.hide();
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Clear suggestions to ensure panel hides
|
||||
setSuggestions([]);
|
||||
|
||||
// Method 3: Force hide via DOM after a tick (fallback)
|
||||
setTimeout(() => {
|
||||
const panel = document.querySelector('.p-autocomplete-panel');
|
||||
if (panel) {
|
||||
panel.style.display = 'none';
|
||||
panel.style.opacity = '0';
|
||||
panel.style.visibility = 'hidden';
|
||||
panel.style.pointerEvents = 'none';
|
||||
}
|
||||
}, 10);
|
||||
}, []);
|
||||
|
||||
const handleSearch = async (searchValue = value) => {
|
||||
hideAutocompletePanel();
|
||||
autoCompleteInputRef.current?.blur(); // blur early so the suggestion panel closes immediately
|
||||
|
||||
const evaluation = evaluateSearchValue(searchValue);
|
||||
@@ -210,6 +275,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
const { artist, song } = evaluation;
|
||||
setIsLoading(true);
|
||||
setLyricsResult(null);
|
||||
setYoutubeVideo(null); // Reset YouTube video
|
||||
setShowLyrics(false);
|
||||
|
||||
const toastId = "lyrics-searching-toast";
|
||||
@@ -251,12 +317,21 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
setTextSize("normal");
|
||||
setLyricsResult({ artist: data.artist, song: data.song, lyrics: data.lyrics });
|
||||
setHighlightedVerse(null);
|
||||
setIsLyricsVisible(false);
|
||||
// Trigger fade-in animation
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setIsLyricsVisible(true));
|
||||
});
|
||||
setShowLyrics(true);
|
||||
|
||||
// Update URL hash with search parameters
|
||||
const hash = `#${encodeURIComponent(data.artist)}/${encodeURIComponent(data.song)}`;
|
||||
window.history.pushState(null, '', hash);
|
||||
|
||||
// Search for YouTube video (don't block on this)
|
||||
fetchYouTubeVideo(data.artist, data.song);
|
||||
|
||||
dismissSearchToast();
|
||||
toast.success(`Found! (Took ${duration}s)`, {
|
||||
autoClose: 2500,
|
||||
@@ -270,6 +345,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
hideAutocompletePanel();
|
||||
autoCompleteInputRef.current?.blur();
|
||||
searchButtonRef.current?.blur();
|
||||
searchToastRef.current = null;
|
||||
@@ -279,6 +355,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
hideAutocompletePanel();
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
@@ -348,8 +425,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
onShow={handlePanelShow}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
style={{ width: '100%', maxWidth: '900px' }}
|
||||
inputStyle={{ width: '100%' }}
|
||||
style={{ width: '100%' }}
|
||||
className={`lyric-search-input ${inputStatus === "error" ? "has-error" : ""} ${inputStatus === "ready" ? "has-ready" : ""}`}
|
||||
aria-invalid={inputStatus === "error"}
|
||||
aria-label={`Lyric search input. ${statusTitle}`}
|
||||
@@ -370,30 +446,31 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
{statusTitle}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleSearch()}
|
||||
className="btn"
|
||||
ref={searchButtonRef}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
<div className="mt-4">
|
||||
Exclude:<br />
|
||||
<div id="exclude-checkboxes">
|
||||
<div className="flex items-center gap-4 mt-5">
|
||||
<Button
|
||||
onClick={() => handleSearch()}
|
||||
className="search-btn"
|
||||
ref={searchButtonRef}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
<div className="h-6 w-px bg-neutral-300 dark:bg-neutral-700" aria-hidden="true"></div>
|
||||
<div className="exclude-sources">
|
||||
<span className="exclude-label">Exclude:</span>
|
||||
<UICheckbox id="excl-Genius" label="Genius" onToggle={toggleExclusion} />
|
||||
<UICheckbox id="excl-LRCLib-Cache" label="LRCLib-Cache" onToggle={toggleExclusion} />
|
||||
<UICheckbox id="excl-LRCLib-Cache" label="LRCLib" onToggle={toggleExclusion} />
|
||||
<UICheckbox id="excl-Cache" label="Cache" onToggle={toggleExclusion} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="mt-3">
|
||||
<div className="mt-6 flex justify-center">
|
||||
<CircularProgress variant="plain" color="primary" size="md" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lyricsResult && (
|
||||
<div className={`lyrics-card lyrics-card-${theme} mt-4 p-4 rounded-md shadow-md`}>
|
||||
<div className={`lyrics-card lyrics-card-${theme} mt-6 p-5 rounded-xl shadow-lg lyrics-card-animate ${isLyricsVisible ? 'lyrics-card-visible' : ''}`}>
|
||||
<div className="lyrics-toolbar">
|
||||
<div className="lyrics-title">
|
||||
{lyricsResult.artist} - {lyricsResult.song}
|
||||
@@ -439,10 +516,40 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
>
|
||||
<LinkIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{youtubeLoading && (
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="plain"
|
||||
color="neutral"
|
||||
aria-label="Loading YouTube video..."
|
||||
title="Finding video..."
|
||||
className="lyrics-action-button"
|
||||
disabled
|
||||
sx={{ opacity: 0.3 }}
|
||||
>
|
||||
<PlayCircleOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
{!youtubeLoading && youtubeVideo && (
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="plain"
|
||||
color="neutral"
|
||||
aria-label="Watch on YouTube"
|
||||
title={`Watch: ${youtubeVideo.title}`}
|
||||
className="lyrics-action-button"
|
||||
component="a"
|
||||
href={`https://www.youtube.com/watch?v=${youtubeVideo.video_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<PlayCircleOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`lyrics-content ${textSize === "large" ? "lyrics-content-large" : ""}`}>
|
||||
<div dangerouslySetInnerHTML={{ __html: lyricsResult.lyrics }} />
|
||||
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(lyricsResult.lyrics) }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -460,10 +567,11 @@ export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
|
||||
}));
|
||||
|
||||
const verifyExclusions = () => {
|
||||
const checkboxes = document.querySelectorAll("#exclude-checkboxes input[type=checkbox]");
|
||||
const checkedCount = [...checkboxes].filter(cb => cb.checked).length;
|
||||
const checkboxes = document.querySelectorAll(".exclude-chip");
|
||||
const checkedCount = [...checkboxes].filter(cb => cb.dataset.checked === 'true').length;
|
||||
|
||||
if (checkedCount === 3) {
|
||||
// Reset all by triggering clicks
|
||||
checkboxes.forEach(cb => cb.click());
|
||||
if (!toast.isActive("lyrics-exclusion-reset-toast")) {
|
||||
toast.error("All sources were excluded; exclusions have been reset.",
|
||||
@@ -473,27 +581,27 @@ export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const newChecked = e.target.checked;
|
||||
const handleClick = () => {
|
||||
const newChecked = !checked;
|
||||
setChecked(newChecked);
|
||||
if (props.onToggle) {
|
||||
const source = props.label; // Use label as source identifier
|
||||
const source = props.label;
|
||||
props.onToggle(source);
|
||||
}
|
||||
verifyExclusions();
|
||||
setTimeout(verifyExclusions, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Checkbox
|
||||
id={props.id}
|
||||
key={props.label}
|
||||
checked={checked}
|
||||
label={props.label}
|
||||
style={{ color: "inherit" }}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
id={props.id}
|
||||
className={`exclude-chip ${checked ? 'exclude-chip--active' : ''}`}
|
||||
data-checked={checked}
|
||||
onClick={handleClick}
|
||||
aria-pressed={checked}
|
||||
>
|
||||
{props.label}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@ const Memes = () => {
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [selectedImage, setSelectedImage] = useState(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [imageLoading, setImageLoading] = useState({});
|
||||
const observerRef = useRef();
|
||||
const touchStartRef = useRef(null);
|
||||
const touchEndRef = useRef(null);
|
||||
const theme = document.documentElement.getAttribute("data-theme")
|
||||
const cacheRef = useRef({ pagesLoaded: new Set(), items: [] });
|
||||
|
||||
@@ -158,6 +161,43 @@ const Memes = () => {
|
||||
setSelectedIndex(-1);
|
||||
}, []);
|
||||
|
||||
// Touch swipe handlers for mobile navigation
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
touchStartRef.current = e.touches[0].clientX;
|
||||
touchEndRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e) => {
|
||||
touchEndRef.current = e.touches[0].clientX;
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (!touchStartRef.current || !touchEndRef.current) return;
|
||||
const distance = touchStartRef.current - touchEndRef.current;
|
||||
const minSwipeDistance = 50;
|
||||
|
||||
if (Math.abs(distance) > minSwipeDistance) {
|
||||
if (distance > 0) {
|
||||
// Swiped left -> next
|
||||
handleNavigate(1);
|
||||
} else {
|
||||
// Swiped right -> prev
|
||||
handleNavigate(-1);
|
||||
}
|
||||
}
|
||||
touchStartRef.current = null;
|
||||
touchEndRef.current = null;
|
||||
}, [handleNavigate]);
|
||||
|
||||
// Track image loading state
|
||||
const handleImageLoad = useCallback((id) => {
|
||||
setImageLoading(prev => ({ ...prev, [id]: false }));
|
||||
}, []);
|
||||
|
||||
const handleImageLoadStart = useCallback((id) => {
|
||||
setImageLoading(prev => ({ ...prev, [id]: true }));
|
||||
}, []);
|
||||
|
||||
const handleCopyImage = useCallback(async () => {
|
||||
if (!selectedImage) return;
|
||||
try {
|
||||
@@ -181,6 +221,7 @@ const Memes = () => {
|
||||
<div className="grid-container">
|
||||
{images.map((img, i) => {
|
||||
const isLast = i === images.length - 1;
|
||||
const isLoading = imageLoading[img.id] !== false;
|
||||
return (
|
||||
<div
|
||||
key={img.id}
|
||||
@@ -191,13 +232,18 @@ const Memes = () => {
|
||||
setSelectedIndex(i);
|
||||
prefetchImage(images[i + 1]);
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
style={{ cursor: 'pointer', position: 'relative' }}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="meme-skeleton" />
|
||||
)}
|
||||
<Image
|
||||
src={img.url}
|
||||
alt={`meme-${img.id}`}
|
||||
imageClassName="meme-img"
|
||||
imageClassName={`meme-img ${isLoading ? 'meme-img-loading' : ''}`}
|
||||
loading="lazy"
|
||||
onLoad={() => handleImageLoad(img.id)}
|
||||
onLoadStart={() => handleImageLoadStart(img.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -239,7 +285,12 @@ const Memes = () => {
|
||||
dismissableMask={true}
|
||||
>
|
||||
{selectedImage && (
|
||||
<div className="meme-dialog-body">
|
||||
<div
|
||||
className="meme-dialog-body"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="meme-dialog-nav meme-dialog-nav-prev"
|
||||
|
||||
@@ -7,26 +7,27 @@ export default function BreadcrumbNav({ currentPage }) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav aria-label="breadcrumb" className="mb-6 flex gap-4 text-sm font-medium text-blue-600 dark:text-blue-400">
|
||||
{pages.map(({ key, label, href }, i) => {
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<a
|
||||
href={href}
|
||||
className={`${currentPage === key
|
||||
? "!font-bold underline" // active: always underlined + bold
|
||||
: "hover:underline" // inactive: underline only on hover
|
||||
}`}
|
||||
aria-current={currentPage === key ? "page" : undefined}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
{i < pages.length - 1 && <span aria-hidden="true">/</span>}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</nav >
|
||||
</div>
|
||||
<nav aria-label="breadcrumb" className="mb-8 flex items-center gap-2 text-sm">
|
||||
{pages.map(({ key, label, href }, i) => {
|
||||
const isActive = currentPage === key;
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<a
|
||||
href={href}
|
||||
className={`px-3 py-1.5 rounded-full transition-colors ${isActive
|
||||
? "bg-neutral-200 dark:bg-neutral-700 font-semibold text-neutral-900 dark:text-white"
|
||||
: "text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
{i < pages.length - 1 && (
|
||||
<span className="text-neutral-400 dark:text-neutral-600" aria-hidden="true">/</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AutoComplete } from "primereact/autocomplete";
|
||||
import { authFetch } from "@/utils/authFetch";
|
||||
import BreadcrumbNav from "./BreadcrumbNav";
|
||||
import { API_URL, ENVIRONMENT } from "@/config";
|
||||
import "./RequestManagement.css";
|
||||
|
||||
export default function MediaRequestForm() {
|
||||
const [type, setType] = useState("artist");
|
||||
@@ -918,7 +919,7 @@ export default function MediaRequestForm() {
|
||||
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto my-10 p-6 rounded-xl shadow-md bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700">
|
||||
<div className="trip-request-form mx-auto my-10 p-6 rounded-xl shadow-md bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700">
|
||||
<style>{`
|
||||
/* Accordion tab backgrounds & text */
|
||||
.p-accordion-tab {
|
||||
@@ -990,7 +991,8 @@ export default function MediaRequestForm() {
|
||||
}
|
||||
`}</style>
|
||||
<BreadcrumbNav currentPage="request" />
|
||||
<h2 className="text-3xl font-semibold mt-0">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>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<label htmlFor="artistInput">Artist: </label>
|
||||
@@ -1051,25 +1053,23 @@ export default function MediaRequestForm() {
|
||||
|
||||
{type === "artist" && albums.length > 0 && (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<div className="flex flex-col gap-2 mb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-400 text-center sm:text-left">
|
||||
<strong className="mr-2">Albums:</strong> {totalAlbums}
|
||||
<span className="mx-3">|</span>
|
||||
<span className="mx-3 sm:inline">|</span>
|
||||
<strong className="mr-2">Tracks:</strong> {totalTracks}
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="#"
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault(); // prevent page jump
|
||||
handleToggleAllAlbums();
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:underline cursor-pointer"
|
||||
>
|
||||
Check / Uncheck All Albums
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleToggleAllAlbums();
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:underline cursor-pointer text-center sm:text-right"
|
||||
>
|
||||
Check / Uncheck All Albums
|
||||
</a>
|
||||
</div>
|
||||
<Accordion
|
||||
multiple
|
||||
@@ -1148,7 +1148,7 @@ export default function MediaRequestForm() {
|
||||
{loadingAlbumId === id && <Spinner />}
|
||||
</span>
|
||||
<small className="ml-2 text-neutral-500 dark:text-neutral-400">({release_date})</small>
|
||||
<span className="ml-auto text-xs text-neutral-500">
|
||||
<span className="ml-0 w-full text-xs text-neutral-500 sm:ml-auto sm:w-auto">
|
||||
{typeof tracksByAlbum[id] === 'undefined' ? (
|
||||
loadingAlbumId === id ? 'Loading...' : '...'
|
||||
) : (
|
||||
@@ -1171,32 +1171,34 @@ export default function MediaRequestForm() {
|
||||
|
||||
return (
|
||||
<li key={track.id} className="py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected?.includes(String(track.id))}
|
||||
onChange={() => toggleTrack(id, track.id)}
|
||||
className="trip-checkbox cursor-pointer"
|
||||
aria-label={`Select track ${track.title} `}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTrackPlayPause(track, id, albumIndex)}
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full border text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${isCurrentTrack && isAudioPlaying
|
||||
? "border-green-600 text-green-600"
|
||||
: "border-neutral-400 text-neutral-600 hover:text-blue-600 hover:border-blue-600"}`}
|
||||
aria-label={`${isCurrentTrack && isAudioPlaying ? "Pause" : "Play"} ${track.title}`}
|
||||
aria-pressed={isCurrentTrack && isAudioPlaying}
|
||||
disabled={audioLoadingTrackId === track.id}
|
||||
>
|
||||
{audioLoadingTrackId === track.id ? (
|
||||
<InlineSpinner sizeClass="h-4 w-4" />
|
||||
) : isCurrentTrack && isAudioPlaying ? (
|
||||
<PauseIcon />
|
||||
) : (
|
||||
<PlayIcon />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected?.includes(String(track.id))}
|
||||
onChange={() => toggleTrack(id, track.id)}
|
||||
className="trip-checkbox cursor-pointer"
|
||||
aria-label={`Select track ${track.title} `}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTrackPlayPause(track, id, albumIndex)}
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full border text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${isCurrentTrack && isAudioPlaying
|
||||
? "border-green-600 text-green-600"
|
||||
: "border-neutral-400 text-neutral-600 hover:text-blue-600 hover:border-blue-600"}`}
|
||||
aria-label={`${isCurrentTrack && isAudioPlaying ? "Pause" : "Play"} ${track.title}`}
|
||||
aria-pressed={isCurrentTrack && isAudioPlaying}
|
||||
disabled={audioLoadingTrackId === track.id}
|
||||
>
|
||||
{audioLoadingTrackId === track.id ? (
|
||||
<InlineSpinner sizeClass="h-4 w-4" />
|
||||
) : isCurrentTrack && isAudioPlaying ? (
|
||||
<PauseIcon />
|
||||
) : (
|
||||
<PlayIcon />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<span className="block truncate" title={track.title}>
|
||||
{truncate(track.title, 80)}
|
||||
@@ -1207,7 +1209,7 @@ export default function MediaRequestForm() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 ml-auto text-xs text-neutral-500">
|
||||
<div className="flex items-center gap-3 text-xs text-neutral-500 w-full justify-between sm:w-auto sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTrackDownload(track)}
|
||||
@@ -1223,7 +1225,7 @@ export default function MediaRequestForm() {
|
||||
</div>
|
||||
</div>
|
||||
{showProgress && (
|
||||
<div className="mt-2 pr-6 pl-16">
|
||||
<div className="mt-2 pr-2 pl-4 sm:pr-6 sm:pl-16">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
|
||||
@@ -1,81 +1,442 @@
|
||||
/* Table and Dark Overrides */
|
||||
.p-datatable {
|
||||
table-layout: fixed !important;
|
||||
.trip-management-container {
|
||||
width: 100%;
|
||||
}
|
||||
.p-datatable td span.truncate {
|
||||
|
||||
.trip-management-container .table-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trip-management-container .p-datatable {
|
||||
width: 100% !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.trip-management-container .p-datatable-wrapper {
|
||||
width: 100% !important;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.trip-management-container .p-datatable-table {
|
||||
width: 100% !important;
|
||||
table-layout: fixed !important;
|
||||
min-width: 100% !important;
|
||||
}
|
||||
|
||||
/* Force header and body rows to fill width */
|
||||
.trip-management-container .p-datatable-thead,
|
||||
.trip-management-container .p-datatable-tbody {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.trip-management-container .p-datatable-thead > tr,
|
||||
.trip-management-container .p-datatable-tbody > tr {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Column widths - distribute across table */
|
||||
.trip-management-container .p-datatable-thead > tr > th,
|
||||
.trip-management-container .p-datatable-tbody > tr > td {
|
||||
/* Default: auto distribute */
|
||||
}
|
||||
|
||||
/* ID column - narrow */
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(1),
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(1) {
|
||||
width: 10% !important;
|
||||
}
|
||||
|
||||
/* Target column - widest */
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(2),
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(2) {
|
||||
width: 22% !important;
|
||||
}
|
||||
|
||||
/* Tracks column */
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(3),
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(3) {
|
||||
width: 10% !important;
|
||||
}
|
||||
|
||||
/* Status column */
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(4),
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(4) {
|
||||
width: 12% !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Progress column */
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(5),
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(5) {
|
||||
width: 16% !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Quality column */
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(6),
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(6) {
|
||||
width: 10% !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Tarball column - fills remaining */
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(7),
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(7) {
|
||||
width: 20% !important;
|
||||
}
|
||||
|
||||
.trip-management-container .p-datatable td span.truncate {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Row hover cursor - indicate clickable */
|
||||
.trip-management-container .p-datatable-tbody > tr {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* Center-align headers for centered columns */
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(4),
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(5),
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(6) {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
/* Skeleton loading styles */
|
||||
.table-skeleton {
|
||||
width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton-row {
|
||||
display: flex;
|
||||
padding: 1rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
|
||||
.skeleton-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.skeleton-cell {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.skeleton-bar {
|
||||
height: 1rem;
|
||||
background: linear-gradient(90deg, #2a2a2a 25%, #3a3a3a 50%, #2a2a2a 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 0.25rem;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Light mode skeleton */
|
||||
[data-theme="light"] .skeleton-bar {
|
||||
background: linear-gradient(90deg, #e5e5e5 25%, #f0f0f0 50%, #e5e5e5 75%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
/* Empty state styles */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 3rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state-text {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.empty-state-subtext {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Dark Mode for Table */
|
||||
[data-theme="dark"] .p-datatable {
|
||||
background-color: #121212 !important;
|
||||
[data-theme="dark"] .trip-management-container .p-datatable {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-thead > tr > th {
|
||||
[data-theme="dark"] .trip-management-container .p-datatable-thead > tr > th {
|
||||
background-color: #1f1f1f !important;
|
||||
color: #e5e7eb !important;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-tbody > tr {
|
||||
[data-theme="dark"] .trip-management-container .p-datatable-tbody > tr {
|
||||
background-color: #1a1a1a !important;
|
||||
border-bottom: 1px solid #374151;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-tbody > tr:nth-child(odd) {
|
||||
[data-theme="dark"] .trip-management-container .p-datatable-tbody > tr:nth-child(odd) {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-tbody > tr:hover {
|
||||
[data-theme="dark"] .trip-management-container .p-datatable-tbody > tr:hover {
|
||||
background-color: #333 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Paginator Dark Mode */
|
||||
[data-theme="dark"] .p-paginator {
|
||||
[data-theme="dark"] .trip-management-container .p-paginator {
|
||||
background-color: #121212 !important;
|
||||
color: #e5e7eb !important;
|
||||
border-top: 1px solid #374151 !important;
|
||||
}
|
||||
[data-theme="dark"] .p-paginator .p-paginator-page,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-next,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-prev,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-first,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-last {
|
||||
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-page,
|
||||
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-next,
|
||||
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-prev,
|
||||
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-first,
|
||||
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-last {
|
||||
color: #e5e7eb !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
[data-theme="dark"] .p-paginator .p-paginator-page:hover,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-next:hover,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-prev:hover {
|
||||
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-page:hover,
|
||||
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-next:hover,
|
||||
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-prev:hover {
|
||||
background-color: #374151 !important;
|
||||
color: #fff !important;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
[data-theme="dark"] .p-paginator .p-highlight {
|
||||
[data-theme="dark"] .trip-management-container .p-paginator .p-highlight {
|
||||
background-color: #6b7280 !important;
|
||||
color: #fff !important;
|
||||
border-radius: 0.25rem !important;
|
||||
}
|
||||
|
||||
/* Dark Mode for PrimeReact Dialog */
|
||||
[data-theme="dark"] .p-dialog {
|
||||
/* Dark Mode for PrimeReact Dialog - rendered via portal so needs global selector */
|
||||
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 {
|
||||
background-color: #1a1a1a !important;
|
||||
color: #e5e7eb !important;
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
[data-theme="dark"] .p-dialog .p-dialog-header {
|
||||
background-color: #121212 !important;
|
||||
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-header {
|
||||
background-color: #171717 !important;
|
||||
color: #e5e7eb !important;
|
||||
border-bottom: 1px solid #374151 !important;
|
||||
}
|
||||
[data-theme="dark"] .p-dialog .p-dialog-content {
|
||||
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-header .p-dialog-header-icon {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-header .p-dialog-header-icon:hover {
|
||||
background-color: #374151 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-content {
|
||||
background-color: #1a1a1a !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
[data-theme="dark"] .p-dialog .p-dialog-footer {
|
||||
background-color: #121212 !important;
|
||||
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-footer {
|
||||
background-color: #171717 !important;
|
||||
border-top: 1px solid #374151 !important;
|
||||
}
|
||||
|
||||
/* Progress Bar Styles */
|
||||
.progress-bar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-bar-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background-color: rgba(128, 128, 128, 0.2);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-track-lg {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-bar-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Container Styles */
|
||||
.trip-management-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trip-management-container .overflow-x-auto {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.trip-request-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.trip-management-container {
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.trip-management-container h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* Stack filters on mobile */
|
||||
.trip-management-container .flex-wrap {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Make table horizontally scrollable */
|
||||
.trip-management-container .overflow-x-auto {
|
||||
margin: 0 -1rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Reduce column widths on mobile */
|
||||
.p-datatable-thead > tr > th,
|
||||
.p-datatable-tbody > tr > td {
|
||||
padding: 0.5rem 0.25rem !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
/* Hide less important columns on small screens */
|
||||
.p-datatable .hide-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.trip-management-container {
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.progress-bar-track {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-bar-text {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== MediaRequestForm Mobile Styles ===== */
|
||||
|
||||
/* Form container responsive */
|
||||
.trip-request-form {
|
||||
width: 100%;
|
||||
max-width: 48rem;
|
||||
margin: 2.5rem auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.trip-request-form {
|
||||
margin: 1rem auto;
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.trip-request-form h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* Quality buttons stack on mobile */
|
||||
.trip-quality-buttons {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.trip-quality-buttons button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Accordion improvements for mobile */
|
||||
.p-accordion-header .p-accordion-header-link {
|
||||
padding: 0.75rem !important;
|
||||
font-size: 0.9rem !important;
|
||||
}
|
||||
|
||||
.p-accordion-content {
|
||||
padding: 0.5rem !important;
|
||||
}
|
||||
|
||||
/* Track list items more compact on mobile */
|
||||
.p-accordion-content li {
|
||||
padding: 0.5rem !important;
|
||||
font-size: 0.85rem !important;
|
||||
}
|
||||
|
||||
/* Album header info stacks */
|
||||
.album-header-info {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Audio player controls smaller on mobile */
|
||||
.track-audio-controls button {
|
||||
padding: 0.25rem 0.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.trip-request-form {
|
||||
margin: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.trip-request-form h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Input fields full width */
|
||||
.p-autocomplete,
|
||||
.p-autocomplete-input {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Smaller text in track listings */
|
||||
.p-accordion-content li span {
|
||||
font-size: 0.75rem !important;
|
||||
}
|
||||
|
||||
/* Submit button full width */
|
||||
.trip-submit-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { authFetch } from "@/utils/authFetch";
|
||||
import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog";
|
||||
import BreadcrumbNav from "./BreadcrumbNav";
|
||||
import { API_URL } from "@/config";
|
||||
import "./RequestManagement.css";
|
||||
|
||||
const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"];
|
||||
const TAR_BASE_URL = "https://codey.lol/m/m2"; // configurable prefix
|
||||
@@ -20,6 +21,7 @@ export default function RequestManagement() {
|
||||
const [filteredRequests, setFilteredRequests] = useState([]);
|
||||
const [selectedRequest, setSelectedRequest] = useState(null);
|
||||
const [isDialogVisible, setIsDialogVisible] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const pollingRef = useRef(null);
|
||||
const pollingDetailRef = useRef(null);
|
||||
|
||||
@@ -30,8 +32,9 @@ export default function RequestManagement() {
|
||||
return `${TAR_BASE_URL}/${quality}/${filename}`;
|
||||
};
|
||||
|
||||
const fetchJobs = async () => {
|
||||
const fetchJobs = async (showLoading = true) => {
|
||||
try {
|
||||
if (showLoading) setIsLoading(true);
|
||||
const res = await authFetch(`${API_URL}/trip/jobs/list`);
|
||||
if (!res.ok) throw new Error("Failed to fetch jobs");
|
||||
const data = await res.json();
|
||||
@@ -43,6 +46,8 @@ export default function RequestManagement() {
|
||||
toastId: 'fetch-fail-toast',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -123,13 +128,13 @@ export default function RequestManagement() {
|
||||
|
||||
|
||||
const statusBodyTemplate = (rowData) => (
|
||||
<span className={`inline-block px-3 py-1 rounded-full font-semibold text-sm ${getStatusColorClass(rowData.status)}`}>
|
||||
<span className={`inline-flex items-center justify-center min-w-[90px] px-3 py-1 rounded-full font-semibold text-xs ${getStatusColorClass(rowData.status)}`}>
|
||||
{rowData.status}
|
||||
</span>
|
||||
);
|
||||
|
||||
const qualityBodyTemplate = (rowData) => (
|
||||
<span className={`inline-block px-3 py-1 rounded-full font-semibold text-sm ${getQualityColorClass(rowData.quality)}`}>
|
||||
<span className={`inline-flex items-center justify-center min-w-[50px] px-3 py-1 rounded-full font-semibold text-xs ${getQualityColorClass(rowData.quality)}`}>
|
||||
{rowData.quality}
|
||||
</span>
|
||||
);
|
||||
@@ -158,6 +163,34 @@ export default function RequestManagement() {
|
||||
return `${pct}%`;
|
||||
};
|
||||
|
||||
const progressBarTemplate = (rowData) => {
|
||||
const p = rowData.progress;
|
||||
if (p === null || p === undefined || p === "") return "—";
|
||||
const num = Number(p);
|
||||
if (Number.isNaN(num)) return "—";
|
||||
const pct = Math.min(100, Math.max(0, num > 1 ? Math.round(num) : num * 100));
|
||||
|
||||
const getProgressColor = () => {
|
||||
if (rowData.status === "Failed") return "bg-red-500";
|
||||
if (rowData.status === "Finished") return "bg-green-500";
|
||||
if (pct < 30) return "bg-blue-400";
|
||||
if (pct < 70) return "bg-blue-500";
|
||||
return "bg-blue-600";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="progress-bar-container">
|
||||
<div className="progress-bar-track">
|
||||
<div
|
||||
className={`progress-bar-fill ${getProgressColor()}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="progress-bar-text">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const confirmDelete = (requestId) => {
|
||||
confirmDialog({
|
||||
message: "Are you sure you want to delete this request?",
|
||||
@@ -195,100 +228,15 @@ export default function RequestManagement() {
|
||||
|
||||
return (
|
||||
|
||||
<div className="w-max my-10 p-6 rounded-xl shadow-md
|
||||
<div className="trip-management-container my-10 p-4 sm:p-6 rounded-xl shadow-md
|
||||
bg-white dark:bg-neutral-900
|
||||
text-neutral-900 dark:text-neutral-100
|
||||
border border-neutral-200 dark:border-neutral-700
|
||||
sm:p-4 md:p-6">
|
||||
<style>{`
|
||||
/* Table and Dark Overrides */
|
||||
.p-datatable {
|
||||
table-layout: fixed !important;
|
||||
}
|
||||
.p-datatable td span.truncate {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable {
|
||||
background-color: #121212 !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-thead > tr > th {
|
||||
background-color: #1f1f1f !important;
|
||||
color: #e5e7eb !important;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-tbody > tr {
|
||||
background-color: #1a1a1a !important;
|
||||
border-bottom: 1px solid #374151;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-tbody > tr:nth-child(odd) {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-tbody > tr:hover {
|
||||
background-color: #333 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
/* Paginator Dark Mode */
|
||||
[data-theme="dark"] .p-paginator {
|
||||
background-color: #121212 !important;
|
||||
color: #e5e7eb !important;
|
||||
border-top: 1px solid #374151 !important;
|
||||
}
|
||||
[data-theme="dark"] .p-paginator .p-paginator-page,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-next,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-prev,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-first,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-last {
|
||||
color: #e5e7eb !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
[data-theme="dark"] .p-paginator .p-paginator-page:hover,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-next:hover,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-prev:hover {
|
||||
background-color: #374151 !important;
|
||||
color: #fff !important;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
[data-theme="dark"] .p-paginator .p-highlight {
|
||||
background-color: #6b7280 !important;
|
||||
color: #fff !important;
|
||||
border-radius: 0.25rem !important;
|
||||
}
|
||||
|
||||
/* Dark mode for PrimeReact Dialog */
|
||||
[data-theme="dark"] .p-dialog {
|
||||
background-color: #1a1a1a !important;
|
||||
color: #e5e7eb !important;
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .p-dialog .p-dialog-header {
|
||||
background-color: #121212 !important;
|
||||
color: #e5e7eb !important;
|
||||
border-bottom: 1px solid #374151 !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .p-dialog .p-dialog-content {
|
||||
background-color: #1a1a1a !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .p-dialog .p-dialog-footer {
|
||||
background-color: #121212 !important;
|
||||
border-top: 1px solid #374151 !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
`}</style>
|
||||
border border-neutral-200 dark:border-neutral-700">
|
||||
|
||||
<BreadcrumbNav currentPage="management" />
|
||||
<h2 className="text-3xl font-semibold mt-0">Media Request Management</h2>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight mb-6">Manage Requests</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-6 mb-6">
|
||||
<div className="flex flex-wrap items-center gap-4 mb-6">
|
||||
<Dropdown
|
||||
value={filterStatus}
|
||||
options={[{ label: "All Statuses", value: "all" }, ...STATUS_OPTIONS.map((s) => ({ label: s, value: s }))]}
|
||||
@@ -298,68 +246,91 @@ export default function RequestManagement() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-max overflow-x-auto rounded-lg">
|
||||
<DataTable
|
||||
value={filteredRequests}
|
||||
paginator
|
||||
rows={10}
|
||||
removableSort
|
||||
sortMode="multiple"
|
||||
emptyMessage="No requests found."
|
||||
onRowClick={handleRowClick}
|
||||
>
|
||||
|
||||
<Column
|
||||
field="id"
|
||||
header="ID"
|
||||
style={{ width: "6rem" }}
|
||||
body={(row) => (
|
||||
<span title={row.id}>
|
||||
{row.id.split("-").slice(-1)[0]}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<Column field="target" header="Target" sortable style={{ width: "12rem" }} body={(row) => textWithEllipsis(row.target, "10rem")} />
|
||||
<Column field="tracks" header="# Tracks" style={{ width: "8rem" }} body={(row) => row.tracks} />
|
||||
<Column field="status" header="Status" body={statusBodyTemplate} style={{ width: "10rem", textAlign: "center" }} sortable />
|
||||
<Column field="progress" header="Progress" body={(row) => formatProgress(row.progress)} style={{ width: "8rem", textAlign: "center" }} sortable />
|
||||
<Column
|
||||
field="quality"
|
||||
header="Quality"
|
||||
body={qualityBodyTemplate}
|
||||
style={{ width: "6rem", textAlign: "center" }}
|
||||
sortable />
|
||||
<Column
|
||||
field="tarball"
|
||||
header={
|
||||
<span className="flex items-center">
|
||||
<i className="pi pi-download mr-1" /> {/* download icon in header */}
|
||||
Tarball
|
||||
</span>
|
||||
{isLoading ? (
|
||||
<div className="table-skeleton">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="skeleton-row">
|
||||
<div className="skeleton-cell w-[10%]"><div className="skeleton-bar" /></div>
|
||||
<div className="skeleton-cell w-[22%]"><div className="skeleton-bar" /></div>
|
||||
<div className="skeleton-cell w-[10%]"><div className="skeleton-bar" /></div>
|
||||
<div className="skeleton-cell w-[12%]"><div className="skeleton-bar" /></div>
|
||||
<div className="skeleton-cell w-[16%]"><div className="skeleton-bar" /></div>
|
||||
<div className="skeleton-cell w-[10%]"><div className="skeleton-bar" /></div>
|
||||
<div className="skeleton-cell w-[20%]"><div className="skeleton-bar" /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper w-full">
|
||||
<DataTable
|
||||
value={filteredRequests}
|
||||
paginator
|
||||
rows={10}
|
||||
removableSort
|
||||
sortMode="multiple"
|
||||
emptyMessage={
|
||||
<div className="empty-state">
|
||||
<i className="pi pi-inbox empty-state-icon" />
|
||||
<p className="empty-state-text">No requests found</p>
|
||||
<p className="empty-state-subtext">Requests you submit will appear here</p>
|
||||
</div>
|
||||
}
|
||||
body={(row) => {
|
||||
const url = tarballUrl(row.tarball, row.quality || "FLAC");
|
||||
const encodedURL = encodeURI(url);
|
||||
if (!url) return "—";
|
||||
onRowClick={handleRowClick}
|
||||
resizableColumns={false}
|
||||
className="w-full"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
|
||||
const fileName = url.split("/").pop();
|
||||
<Column
|
||||
field="id"
|
||||
header="ID"
|
||||
body={(row) => (
|
||||
<span title={row.id}>
|
||||
{row.id.split("-").slice(-1)[0]}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<Column field="target" header="Target" sortable body={(row) => textWithEllipsis(row.target, "100%")} />
|
||||
<Column field="tracks" header="# Tracks" body={(row) => row.tracks} />
|
||||
<Column field="status" header="Status" body={statusBodyTemplate} style={{ textAlign: "center" }} sortable />
|
||||
<Column field="progress" header="Progress" body={progressBarTemplate} style={{ textAlign: "center" }} sortable />
|
||||
<Column
|
||||
field="quality"
|
||||
header="Quality"
|
||||
body={qualityBodyTemplate}
|
||||
style={{ textAlign: "center" }}
|
||||
sortable />
|
||||
<Column
|
||||
field="tarball"
|
||||
header={
|
||||
<span className="flex items-center">
|
||||
<i className="pi pi-download mr-1" />
|
||||
Tarball
|
||||
</span>
|
||||
}
|
||||
body={(row) => {
|
||||
const url = tarballUrl(row.tarball, row.quality || "FLAC");
|
||||
const encodedURL = encodeURI(url);
|
||||
if (!url) return "—";
|
||||
|
||||
return (
|
||||
<a
|
||||
href={encodedURL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-blue-500 hover:underline"
|
||||
title={fileName}
|
||||
>
|
||||
{truncate(fileName, 16)}
|
||||
</a>
|
||||
);
|
||||
}}
|
||||
style={{ width: "10rem" }}
|
||||
/>
|
||||
</DataTable>
|
||||
</div>
|
||||
const fileName = url.split("/").pop();
|
||||
|
||||
return (
|
||||
<a
|
||||
href={encodedURL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-blue-500 hover:underline"
|
||||
title={fileName}
|
||||
>
|
||||
{truncate(fileName, 28)}
|
||||
</a>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</DataTable>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog />
|
||||
|
||||
@@ -402,7 +373,18 @@ export default function RequestManagement() {
|
||||
</p>
|
||||
)}
|
||||
{selectedRequest.progress !== undefined && selectedRequest.progress !== null && (
|
||||
<p><strong>Progress:</strong> {formatProgress(selectedRequest.progress)}</p>
|
||||
<div className="col-span-2">
|
||||
<strong>Progress:</strong>
|
||||
<div className="progress-bar-container mt-2">
|
||||
<div className="progress-bar-track progress-bar-track-lg">
|
||||
<div
|
||||
className={`progress-bar-fill ${selectedRequest.status === "Failed" ? "bg-red-500" : selectedRequest.status === "Finished" ? "bg-green-500" : "bg-blue-500"}`}
|
||||
style={{ width: `${Math.min(100, Math.max(0, Number(selectedRequest.progress) > 1 ? Math.round(selectedRequest.progress) : selectedRequest.progress * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="progress-bar-text">{formatProgress(selectedRequest.progress)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import React from 'react';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
const CustomToastContainer = () => {
|
||||
const CustomToastContainer = ({ theme = 'light', newestOnTop = false, closeOnClick = true }) => {
|
||||
// Map data-theme values to react-toastify theme
|
||||
const toastTheme = theme === 'dark' ? 'dark' : 'light';
|
||||
|
||||
return (
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
autoClose={5000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop={false}
|
||||
closeOnClick
|
||||
newestOnTop={newestOnTop}
|
||||
closeOnClick={closeOnClick}
|
||||
rtl={false}
|
||||
pauseOnFocusLoss={false}
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme={toastTheme}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
242
src/components/req/ReqForm.jsx
Normal file
242
src/components/req/ReqForm.jsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { Button } from "@mui/joy";
|
||||
// Dropdown not used in this form; removed to avoid unused-import warnings
|
||||
import { AutoComplete } from "primereact/autocomplete/autocomplete.esm.js";
|
||||
import { InputText } from "primereact/inputtext/inputtext.esm.js";
|
||||
|
||||
export default function ReqForm() {
|
||||
const [type, setType] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [year, setYear] = useState("");
|
||||
const [requester, setRequester] = useState("");
|
||||
const [selectedItem, setSelectedItem] = useState(null);
|
||||
const [selectedOverview, setSelectedOverview] = useState("");
|
||||
const [selectedTitle, setSelectedTitle] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
const [posterLoading, setPosterLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (title !== selectedTitle) {
|
||||
if (selectedOverview) setSelectedOverview("");
|
||||
if (selectedItem) setSelectedItem(null);
|
||||
if (type) setType("");
|
||||
if (selectedTitle) setSelectedTitle("");
|
||||
setPosterLoading(true);
|
||||
}
|
||||
}, [title, selectedTitle, selectedOverview, selectedItem, type]);
|
||||
|
||||
const searchTitles = async (event) => {
|
||||
const query = event.query;
|
||||
if (query.length < 2) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
setSuggestions(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching suggestions:', error);
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) {
|
||||
toast.error("Please fill in the required fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const response = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ title, year, type, requester }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Submission failed');
|
||||
}
|
||||
toast.success("Request submitted successfully!");
|
||||
// Reset form
|
||||
setType("");
|
||||
setTitle("");
|
||||
setYear("");
|
||||
setRequester("");
|
||||
setSelectedOverview("");
|
||||
setSelectedTitle("");
|
||||
setSelectedItem(null);
|
||||
} catch (error) {
|
||||
console.error('Submission error:', error);
|
||||
toast.error("Failed to submit request. Please try again.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const attachScrollFix = () => {
|
||||
setTimeout(() => {
|
||||
const panel = document.querySelector(".p-autocomplete-panel");
|
||||
const items = panel?.querySelector(".p-autocomplete-items");
|
||||
if (items) {
|
||||
items.style.maxHeight = "200px";
|
||||
items.style.overflowY = "auto";
|
||||
items.style.overscrollBehavior = "contain";
|
||||
const wheelHandler = (e) => {
|
||||
const delta = e.deltaY;
|
||||
const atTop = items.scrollTop === 0;
|
||||
const atBottom = items.scrollTop + items.clientHeight >= items.scrollHeight;
|
||||
if ((delta < 0 && atTop) || (delta > 0 && atBottom)) {
|
||||
e.preventDefault();
|
||||
} else {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
items.removeEventListener("wheel", wheelHandler);
|
||||
items.addEventListener("wheel", wheelHandler, { passive: false });
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const formatMediaType = (mediaTypeValue) => {
|
||||
if (!mediaTypeValue) return "";
|
||||
if (mediaTypeValue === "tv") return "TV Series";
|
||||
if (mediaTypeValue === "movie") return "Movie";
|
||||
return mediaTypeValue.charAt(0).toUpperCase() + mediaTypeValue.slice(1);
|
||||
};
|
||||
|
||||
const selectedTypeLabel = formatMediaType(selectedItem?.mediaType || type);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh] p-4">
|
||||
<div className="w-full max-w-lg p-8 bg-white dark:bg-[#141414] rounded-2xl shadow-lg shadow-neutral-900/5 dark:shadow-black/20 border border-neutral-200/60 dark:border-neutral-800/60">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white mb-2 tracking-tight font-['IBM_Plex_Sans',sans-serif]">
|
||||
Request Movies/TV
|
||||
</h1>
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm">
|
||||
Submit your request for review
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="title" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<AutoComplete
|
||||
id="title"
|
||||
value={title}
|
||||
suggestions={suggestions}
|
||||
completeMethod={searchTitles}
|
||||
minLength={2}
|
||||
onChange={(e) => setTitle(typeof e.value === 'string' ? e.value : e.value.label)}
|
||||
onSelect={(e) => {
|
||||
setType(e.value.mediaType === 'tv' ? 'tv' : 'movie');
|
||||
setTitle(e.value.label);
|
||||
setSelectedTitle(e.value.label);
|
||||
setSelectedItem(e.value);
|
||||
if (e.value.year) setYear(e.value.year);
|
||||
setSelectedOverview(e.value.overview || "");
|
||||
}}
|
||||
placeholder="Enter movie or TV title"
|
||||
title="Enter movie or TV show title"
|
||||
className="w-full"
|
||||
inputClassName="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
|
||||
panelClassName="rounded-xl overflow-hidden"
|
||||
field="label"
|
||||
onShow={attachScrollFix}
|
||||
itemTemplate={(item) => (
|
||||
<div className="p-2 rounded">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
{item.year && <span className="text-sm text-neutral-500 ml-2">({item.year})</span>}
|
||||
<span className="text-xs text-neutral-400 ml-2 uppercase">{item.mediaType === 'tv' ? 'TV' : 'Movie'}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{selectedItem && selectedTypeLabel && (
|
||||
<div className="text-xs font-medium uppercase text-neutral-500 dark:text-neutral-400 tracking-wide">
|
||||
Selected type: <span className="font-bold text-neutral-700 dark:text-neutral-200">{selectedTypeLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedOverview && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Synopsis
|
||||
</label>
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-xl border border-neutral-200/60 dark:border-neutral-700/60">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 leading-relaxed">
|
||||
{selectedOverview}
|
||||
</p>
|
||||
</div>
|
||||
{selectedItem?.poster_path && (
|
||||
<div className="relative w-24 sm:w-32 md:w-40 flex-shrink-0 overflow-hidden rounded-lg">
|
||||
{posterLoading && (
|
||||
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-lg animate-pulse" style={{ aspectRatio: '2/3' }} />
|
||||
)}
|
||||
<img
|
||||
src={`https://image.tmdb.org/t/p/w200${selectedItem.poster_path}`}
|
||||
alt="Poster"
|
||||
className={`w-full h-auto rounded-lg border border-neutral-200 dark:border-neutral-700 transition-opacity duration-300 ${posterLoading ? 'hidden' : 'block'}`}
|
||||
onLoad={() => setPosterLoading(false)}
|
||||
onError={() => setPosterLoading(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="year" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Year <span className="text-neutral-400">(optional)</span>
|
||||
</label>
|
||||
<InputText
|
||||
id="year"
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
placeholder="e.g. 2023"
|
||||
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="requester" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Your Name <span className="text-neutral-400">(optional)</span>
|
||||
</label>
|
||||
<InputText
|
||||
id="requester"
|
||||
value={requester}
|
||||
onChange={(e) => setRequester(e.target.value)}
|
||||
placeholder="Who is requesting this?"
|
||||
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-3 px-6 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 font-semibold rounded-xl hover:bg-neutral-800 dark:hover:bg-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
|
||||
>
|
||||
{isSubmitting ? "Submitting..." : "Submit Request"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,14 @@ export const metaData = {
|
||||
description: "CODEY STUFF!",
|
||||
shareTitle: "CODEY STUFF",
|
||||
shareDescription: "CODEY STUFF!",
|
||||
shareImageAlt: "/images/favicon.png",
|
||||
// alt text for the share/OG image (descriptive; not a filename)
|
||||
shareImageAlt: "CODEY STUFF logo",
|
||||
// default favicon / site icon used when no per-subsite override is provided
|
||||
favicon: "/images/favicon.png",
|
||||
// additional icons array (optional) for multi-icon support
|
||||
icons: [
|
||||
{ rel: 'icon', href: '/images/favicon.png' },
|
||||
],
|
||||
};
|
||||
|
||||
export const API_URL = "https://api.codey.lol";
|
||||
@@ -16,6 +23,51 @@ export const RADIO_API_URL = "https://radio-api.codey.lol";
|
||||
export const socialLinks = {
|
||||
};
|
||||
|
||||
export const MAJOR_VERSION = "0.3"
|
||||
export const MAJOR_VERSION = "0.5"
|
||||
export const RELEASE_FLAG = null;
|
||||
export const ENVIRONMENT = import.meta.env.DEV ? "Dev" : "Prod";
|
||||
export const ENVIRONMENT = import.meta.env.DEV ? "Dev" : "Prod";
|
||||
|
||||
// Whitelabel overrides
|
||||
export const WHITELABELS = {
|
||||
'req.boatson.boats': {
|
||||
title: 'Request Media',
|
||||
name: 'REQ',
|
||||
brandColor: '', // inherit
|
||||
siteTitle: 'Request Media',
|
||||
logoText: 'Request Media',
|
||||
// optional meta overrides for whitelabel/subsite
|
||||
shareTitle: 'Request Media',
|
||||
shareDescription: 'Request Media',
|
||||
ogImage: '/images/req.png',
|
||||
shareImageAlt: 'Request Media logo',
|
||||
favicon: '/images/req.png',
|
||||
// optional canonical/base url for this subsite (useful when host-forcing or in prod)
|
||||
baseUrl: 'https://req.boatson.boats',
|
||||
// human-readable site name for social meta
|
||||
siteName: 'Request Media'
|
||||
},
|
||||
};
|
||||
|
||||
// Subsite mapping: host -> site path
|
||||
export const SUBSITES = {
|
||||
'req.boatson.boats': '/subsites/req',
|
||||
};
|
||||
|
||||
// Protected routes configuration
|
||||
// Routes listed here require authentication - middleware will redirect to /login if not authenticated
|
||||
// Can be a string (just auth required) or object with roles array for role-based access
|
||||
export const PROTECTED_ROUTES = [
|
||||
'/radio',
|
||||
{ path: '/lighting', roles: ['lighting'] },
|
||||
{ path: '/discord-logs', roles: ['discord'] },
|
||||
'/memes',
|
||||
'/TRip',
|
||||
'/TRip/requests',
|
||||
];
|
||||
|
||||
// Routes that should skip auth check entirely (public routes)
|
||||
export const PUBLIC_ROUTES = [
|
||||
'/',
|
||||
'/login',
|
||||
'/api/',
|
||||
];
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
16
src/env.d.ts
vendored
16
src/env.d.ts
vendored
@@ -1 +1,17 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
|
||||
// Extend Astro.locals with custom properties set by middleware
|
||||
declare namespace App {
|
||||
interface Locals {
|
||||
user?: {
|
||||
id?: string;
|
||||
username?: string;
|
||||
user?: string;
|
||||
roles?: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
whitelabel?: string | null;
|
||||
isSubsite?: boolean;
|
||||
refreshedCookies?: string[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
// requireAuthHook.js
|
||||
import { API_URL } from "@/config";
|
||||
|
||||
// WeakMap to cache auth promises per Astro context (request)
|
||||
const authCache = new WeakMap();
|
||||
|
||||
export const requireAuthHook = async (Astro) => {
|
||||
// Check if we already have a cached promise for this request
|
||||
if (authCache.has(Astro)) {
|
||||
return authCache.get(Astro);
|
||||
}
|
||||
|
||||
// Create a promise and cache it immediately to prevent race conditions
|
||||
const authPromise = performAuth(Astro);
|
||||
authCache.set(Astro, authPromise);
|
||||
|
||||
// Return the promise - all callers will await the same promise
|
||||
return authPromise;
|
||||
};
|
||||
|
||||
async function performAuth(Astro) {
|
||||
try {
|
||||
const cookieHeader = Astro.request.headers.get("cookie") ?? "";
|
||||
let res = await fetch(`${API_URL}/auth/id`, {
|
||||
@@ -21,19 +38,30 @@ export const requireAuthHook = async (Astro) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const setCookieHeader = refreshRes.headers.get("set-cookie");
|
||||
let newCookieHeader = cookieHeader;
|
||||
|
||||
if (setCookieHeader) {
|
||||
const cookiesArray = setCookieHeader.split(/,(?=\s*\w+=)/);
|
||||
cookiesArray.forEach((c) => Astro.response.headers.append("set-cookie", c));
|
||||
|
||||
newCookieHeader = cookiesArray.map(c => c.split(";")[0]).join("; ");
|
||||
// Get all Set-Cookie headers (getSetCookie returns an array)
|
||||
let setCookies = [];
|
||||
if (typeof refreshRes.headers.getSetCookie === 'function') {
|
||||
setCookies = refreshRes.headers.getSetCookie();
|
||||
} else {
|
||||
// Fallback for older Node versions
|
||||
const setCookieHeader = refreshRes.headers.get("set-cookie");
|
||||
if (setCookieHeader) {
|
||||
// Split on comma followed by a cookie name (word=), avoiding splitting on Expires dates
|
||||
setCookies = setCookieHeader.split(/,(?=\s*[a-zA-Z_][a-zA-Z0-9_]*=)/);
|
||||
}
|
||||
}
|
||||
|
||||
if (setCookies.length === 0) {
|
||||
console.error("No set-cookie header found in refresh response");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Forward cookies to client
|
||||
setCookies.forEach((c) => Astro.response.headers.append("set-cookie", c.trim()));
|
||||
|
||||
// Build new cookie header for the retry request
|
||||
const newCookieHeader = setCookies.map(c => c.split(";")[0].trim()).join("; ");
|
||||
|
||||
res = await fetch(`${API_URL}/auth/id`, {
|
||||
headers: { Cookie: newCookieHeader },
|
||||
credentials: "include",
|
||||
@@ -52,4 +80,4 @@ export const requireAuthHook = async (Astro) => {
|
||||
console.error("[SSR] requireAuthHook error:", err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,15 +3,19 @@ interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
hideFooter?: boolean;
|
||||
}
|
||||
|
||||
import Themes from "astro-themes";
|
||||
import { ViewTransitions } from "astro:transitions";
|
||||
|
||||
import BaseHead from "../components/BaseHead.astro";
|
||||
import Navbar from "./Nav.astro";
|
||||
import { metaData } from "../config";
|
||||
import Nav from "./Nav.astro";
|
||||
import SubNav from "./SubNav.astro";
|
||||
import Footer from "../components/Footer.astro";
|
||||
|
||||
import "@fontsource/ibm-plex-sans/500.css";
|
||||
import "@fontsource/ibm-plex-sans/600.css";
|
||||
import "@fontsource/geist-sans/400.css";
|
||||
import "@fontsource/geist-sans/600.css";
|
||||
import "@fontsource/geist-mono/400.css";
|
||||
@@ -19,41 +23,85 @@ import "@fontsource/geist-mono/600.css";
|
||||
import "@styles/global.css";
|
||||
import "@fonts/fonts.css";
|
||||
|
||||
const { title, description, image, hideFooter = false } = Astro.props;
|
||||
const hostHeader = Astro.request?.headers?.get('host') || '';
|
||||
const host = hostHeader.split(':')[0];
|
||||
|
||||
import { getSubsiteByHost, getSubsiteFromSignal } from '../utils/subsites.js';
|
||||
|
||||
const { title, description, image } = Astro.props;
|
||||
// Determine if this request maps to a subsite (either via host or path)
|
||||
// support legacy detection if path starts with /subsites/req — default host should match SUBSITES
|
||||
// also support path-layout detection (e.g. /subsites/req)
|
||||
import { getSubsiteByPath } from '../utils/subsites.js';
|
||||
const detectedSubsite = getSubsiteByHost(host) ?? getSubsiteByPath(Astro.url.pathname) ?? null;
|
||||
const isReq = detectedSubsite?.short === 'req';
|
||||
const isReqSubdomain = host?.startsWith('req.');
|
||||
|
||||
import { WHITELABELS } from "../config";
|
||||
|
||||
// Accept forced whitelabel via query param, headers or request locals (set by middleware)
|
||||
const forcedParam = Astro.url.searchParams.get('whitelabel') || Astro.request?.headers?.get('x-whitelabel') || (Astro.request as any)?.locals?.whitelabel || null;
|
||||
|
||||
let whitelabel: any = null;
|
||||
if (forcedParam) {
|
||||
const forced = getSubsiteFromSignal(forcedParam);
|
||||
if (forced) whitelabel = WHITELABELS[forced.host] ?? null;
|
||||
}
|
||||
|
||||
// fallback: by host mapping or legacy /subsites/req detection
|
||||
if (!whitelabel) {
|
||||
whitelabel = WHITELABELS[host] ?? (isReq ? WHITELABELS[detectedSubsite.host] : null);
|
||||
}
|
||||
|
||||
// Determine whether we consider this request a subsite. Middleware will set
|
||||
// request locals.isSubsite, which we trust here, but as a fallback also use
|
||||
// the presence of a whitelabel mapping or a detected subsite path.
|
||||
const isSubsite = (Astro.request as any)?.locals?.isSubsite ?? Boolean(whitelabel || detectedSubsite);
|
||||
|
||||
// Debug logging
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`[Base.astro] host: ${host}, forcedParam: ${forcedParam}, isReq: ${isReq}, whitelabel: ${JSON.stringify(whitelabel)}`);
|
||||
}
|
||||
---
|
||||
|
||||
<html lang="en" class="scrollbar-hide lenis lenis-smooth">
|
||||
<html lang="en" class="scrollbar-hide lenis lenis-smooth" data-subsite={isSubsite ? 'true' : 'false'}>
|
||||
<head>
|
||||
<ViewTransitions />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta
|
||||
name="googlebot"
|
||||
content="index, follow, max-video-preview:-1, max-image-preview:large, max-snippet:-1"
|
||||
/>
|
||||
<Themes />
|
||||
<BaseHead title={title} description={description} image={image} />
|
||||
<BaseHead title={whitelabel?.siteTitle ?? title} description={description} image={image ?? metaData.ogImage} isWhitelabel={!!whitelabel} whitelabel={whitelabel} subsite={detectedSubsite} />
|
||||
<script>
|
||||
import "@scripts/lenisSmoothScroll.js";
|
||||
import "@scripts/main.jsx";
|
||||
</script>
|
||||
</head>
|
||||
<body
|
||||
class="antialiased flex flex-col items-center justify-center mx-auto mt-2 lg:mt-8 mb-20 lg:mb-40
|
||||
scrollbar-hide">
|
||||
class="antialiased flex flex-col items-center mx-auto min-h-screen
|
||||
scrollbar-hide"
|
||||
style={`--brand-color: ${whitelabel?.brandColor ?? '#111827'}`}>
|
||||
|
||||
<!-- Nav is outside main to allow full-width -->
|
||||
<div class="w-full">
|
||||
{whitelabel ? <SubNav whitelabel={whitelabel} subsite={detectedSubsite} /> : <Nav />}
|
||||
</div>
|
||||
|
||||
<main
|
||||
class="flex-auto min-w-0 mt-2 md:mt-6 flex flex-col px-6 sm:px-4 md:px-0 max-w-3xl w-full">
|
||||
class="page-enter flex-auto min-w-0 mt-6 md:mt-8 flex flex-col px-4 sm:px-6 md:px-0 max-w-3xl w-full pb-8">
|
||||
<noscript>
|
||||
<div style="background: #f44336; color: white; padding: 1em; text-align: center;">
|
||||
This site requires JavaScript to function. Please enable JavaScript in your browser.
|
||||
</div>
|
||||
</noscript>
|
||||
<Navbar />
|
||||
<slot />
|
||||
<Footer />
|
||||
{!hideFooter && <Footer />}
|
||||
</main>
|
||||
<style>
|
||||
/* Minimal page transition to replace deprecated ViewTransitions */
|
||||
.page-enter { opacity: 0; transform: translateY(6px); transition: opacity 220ms ease, transform 240ms ease; }
|
||||
html.page-ready .page-enter { opacity: 1; transform: none; }
|
||||
/* CSS rules for the page scrollbar */
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
@@ -64,5 +112,20 @@ const { title, description, image } = Astro.props;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Mark the page ready so CSS transitions run (replaces ViewTransitions).
|
||||
// We don't rely on any Astro-specific API here — just a small client-side toggle.
|
||||
(function () {
|
||||
try {
|
||||
// Add page-ready on the next animation frame so the transition always runs.
|
||||
if (typeof window !== 'undefined') {
|
||||
requestAnimationFrame(() => document.documentElement.classList.add('page-ready'));
|
||||
}
|
||||
} catch (e) {
|
||||
// no-op
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,6 +6,12 @@ import { padlockIconSvg, userIconSvg, externalLinkIconSvg } from "@/utils/navAss
|
||||
import "@/assets/styles/nav.css";
|
||||
|
||||
const user = await requireAuthHook(Astro);
|
||||
const hostHeader = Astro.request?.headers?.get('host') || '';
|
||||
const host = hostHeader.split(':')[0];
|
||||
import { getSubsiteByHost } from '../utils/subsites.js';
|
||||
import { getSubsiteByPath } from '../utils/subsites.js';
|
||||
const isReq = getSubsiteByHost(host)?.short === 'req' || getSubsiteByPath(Astro.url.pathname)?.short === 'req';
|
||||
// Nav is the standard site navigation — whitelabel logic belongs in SubNav
|
||||
const isLoggedIn = Boolean(user);
|
||||
const userDisplayName = user?.user ?? null;
|
||||
|
||||
@@ -15,6 +21,7 @@ const navItems = [
|
||||
{ label: "Memes", href: "/memes" },
|
||||
{ label: "TRip", href: "/TRip", auth: true, icon: "pirate" },
|
||||
{ label: "Lighting", href: "/lighting", auth: true },
|
||||
{ label: "Discord Logs", href: "/discord-logs", auth: true },
|
||||
{ label: "Status", href: "https://status.boatson.boats", icon: "external" },
|
||||
{ label: "Git", href: "https://kode.boatson.boats", icon: "external" },
|
||||
{ label: "Login", href: "/login", guestOnly: true },
|
||||
@@ -39,16 +46,15 @@ const currentPath = Astro.url.pathname;
|
||||
|
||||
<script src="/scripts/nav-controls.js" defer data-api-url={API_URL}></script>
|
||||
|
||||
<nav class="w-full px-4 sm:px-6 py-4 bg-transparent sticky top-0 z-50 backdrop-blur-sm bg-white/80 dark:bg-[#121212]/80 border-b border-neutral-200/50 dark:border-neutral-800/50">
|
||||
<nav class="w-full px-4 sm:px-6 py-3 sticky top-0 z-50 backdrop-blur-xl bg-white/75 dark:bg-[#0a0a0a]/75 border-b border-neutral-200/40 dark:border-neutral-800/40 shadow-sm shadow-neutral-900/5 dark:shadow-black/20">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="nav-bar-row flex items-center gap-4 justify-between">
|
||||
<!-- Logo/Brand -->
|
||||
<a
|
||||
href="/"
|
||||
class="text-xl sm:text-2xl font-semibold header-text whitespace-nowrap hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{metaData.title}
|
||||
</a>
|
||||
href="/"
|
||||
class="text-xl sm:text-2xl font-bold tracking-tight bg-gradient-to-r from-neutral-900 to-neutral-600 dark:from-white dark:to-neutral-400 bg-clip-text text-transparent whitespace-nowrap hover:opacity-80 transition-opacity font-['IBM_Plex_Sans',sans-serif]">
|
||||
{metaData.title}
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="desktop-nav flex items-center">
|
||||
@@ -70,8 +76,8 @@ const currentPath = Astro.url.pathname;
|
||||
<a
|
||||
href={item.href}
|
||||
class={isActive
|
||||
? "flex items-center gap-0 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all duration-200 bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-900"
|
||||
: "flex items-center gap-0 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
? "flex items-center gap-0.5 px-3 py-1.5 rounded-lg text-[13px] font-semibold transition-all duration-200 text-white bg-neutral-900 dark:bg-white dark:text-neutral-900 shadow-sm font-['IBM_Plex_Sans',sans-serif]"
|
||||
: "flex items-center gap-0.5 px-3 py-1.5 rounded-lg text-[13px] font-medium transition-all duration-200 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800/60 font-['IBM_Plex_Sans',sans-serif]"
|
||||
}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||
@@ -174,9 +180,10 @@ const currentPath = Astro.url.pathname;
|
||||
<a
|
||||
href={item.href}
|
||||
class={isActive
|
||||
? "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-900"
|
||||
? "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 text-white"
|
||||
: "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
}
|
||||
style={isActive ? `background: #111827` : undefined}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||
onclick={item.onclick}
|
||||
|
||||
11
src/layouts/SubNav.astro
Normal file
11
src/layouts/SubNav.astro
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
// Generic subsite navigation router — renders a per-subsite nav when available
|
||||
// Props: whitelabel: object, subsite: { host, path, short }
|
||||
const whitelabel = Astro.props?.whitelabel ?? null;
|
||||
const subsite = Astro.props?.subsite ?? null;
|
||||
|
||||
import ReqSubNav from './subsites/reqNav.astro';
|
||||
import DefaultSubNav from './subsites/defaultNav.astro';
|
||||
---
|
||||
|
||||
{subsite?.short === 'req' ? <ReqSubNav whitelabel={whitelabel} /> : <DefaultSubNav whitelabel={whitelabel} />}
|
||||
27
src/layouts/WhitelabelLayout.jsx
Normal file
27
src/layouts/WhitelabelLayout.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const WhitelabelLayout = ({ children, header, footer, customStyles }) => {
|
||||
return (
|
||||
<div style={customStyles} className="whitelabel-layout">
|
||||
{header && <header className="whitelabel-header">{header}</header>}
|
||||
<main className="whitelabel-main">{children}</main>
|
||||
{footer && <footer className="whitelabel-footer">{footer}</footer>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
WhitelabelLayout.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
header: PropTypes.node,
|
||||
footer: PropTypes.node,
|
||||
customStyles: PropTypes.object,
|
||||
};
|
||||
|
||||
WhitelabelLayout.defaultProps = {
|
||||
header: null,
|
||||
footer: null,
|
||||
customStyles: {},
|
||||
};
|
||||
|
||||
export default WhitelabelLayout;
|
||||
21
src/layouts/subsites/defaultNav.astro
Normal file
21
src/layouts/subsites/defaultNav.astro
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
// Default subsite nav — used when a subsite exists but no specialized nav is available
|
||||
import { API_URL } from "../../config";
|
||||
const whitelabel = Astro.props?.whitelabel ?? null;
|
||||
const currentPath = Astro.url.pathname;
|
||||
---
|
||||
|
||||
<script src="/scripts/nav-controls.js" defer data-api-url={API_URL}></script>
|
||||
|
||||
<nav class="w-full px-4 sm:px-6 py-3 sticky top-0 z-50 backdrop-blur-xl bg-white/75 dark:bg-[#0a0a0a]/75 border-b border-neutral-200/40 dark:border-neutral-800/40 shadow-sm shadow-neutral-900/5 dark:shadow-black/20">
|
||||
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<a href="/" class="text-xl sm:text-2xl font-bold tracking-tight bg-gradient-to-r from-neutral-900 to-neutral-600 dark:from-white dark:to-neutral-400 bg-clip-text text-transparent whitespace-nowrap hover:opacity-80 transition-opacity font-['IBM_Plex_Sans',sans-serif]">
|
||||
{whitelabel?.logoText ?? 'Subsite'}
|
||||
</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- placeholder for future global subsite nav items -->
|
||||
<button aria-label="Toggle theme" type="button" class="flex items-center justify-center w-8 h-8 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors" onclick="toggleTheme()">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
182
src/layouts/subsites/reqNav.astro
Normal file
182
src/layouts/subsites/reqNav.astro
Normal file
@@ -0,0 +1,182 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { API_URL } from "../../config";
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
import { padlockIconSvg, userIconSvg, externalLinkIconSvg } from "@/utils/navAssets";
|
||||
import "@/assets/styles/nav.css";
|
||||
|
||||
const whitelabel = Astro.props?.whitelabel ?? null;
|
||||
const currentPath = Astro.url.pathname;
|
||||
const subsitePathPrefix = "/subsites/req";
|
||||
|
||||
const user = await requireAuthHook(Astro);
|
||||
const isLoggedIn = Boolean(user);
|
||||
const userDisplayName = user?.user ?? null;
|
||||
|
||||
type NavItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
auth?: boolean;
|
||||
guestOnly?: boolean;
|
||||
icon?: "external" | "padlock" | "pirate" | string;
|
||||
onclick?: string;
|
||||
};
|
||||
|
||||
const baseNavItems: NavItem[] = [
|
||||
// { label: "Submit Request", href: "/" },
|
||||
];
|
||||
|
||||
const navItems: NavItem[] = isLoggedIn
|
||||
? [...baseNavItems, { label: "Logout", href: "#logout", onclick: "handleLogout()" }]
|
||||
: baseNavItems;
|
||||
|
||||
const visibleNavItems: NavItem[] = navItems.filter((item) => {
|
||||
if (item.auth && !isLoggedIn) return false;
|
||||
if (item.guestOnly && isLoggedIn) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const normalize = (url) => (url || "/").replace(/\/+$/, "") || "/";
|
||||
const trimmedPath = currentPath.startsWith(subsitePathPrefix)
|
||||
? currentPath.slice(subsitePathPrefix.length) || "/"
|
||||
: currentPath;
|
||||
const normalizedCurrent = normalize(trimmedPath);
|
||||
---
|
||||
|
||||
<script src="/scripts/nav-controls.js" defer data-api-url={API_URL}></script>
|
||||
|
||||
<nav class="w-full px-4 sm:px-6 py-3 sticky top-0 z-50 backdrop-blur-xl bg-white/75 dark:bg-[#0a0a0a]/75 border-b border-neutral-200/40 dark:border-neutral-800/40 shadow-sm shadow-neutral-900/5 dark:shadow-black/20">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="nav-bar-row flex items-center gap-4 justify-between">
|
||||
<a
|
||||
href="/"
|
||||
class="text-xl sm:text-2xl font-bold tracking-tight bg-gradient-to-r from-neutral-900 to-neutral-600 dark:from-white dark:to-neutral-400 bg-clip-text text-transparent whitespace-nowrap hover:opacity-80 transition-opacity font-['IBM_Plex_Sans',sans-serif]"
|
||||
>
|
||||
{whitelabel?.logoText ?? 'REQ'}
|
||||
</a>
|
||||
|
||||
<div class="desktop-nav flex items-center">
|
||||
<ul class="desktop-nav-list">
|
||||
{visibleNavItems.map((item) => {
|
||||
const isExternal = item.href?.startsWith("http");
|
||||
const isAuthedPath = item.auth ?? false;
|
||||
const normalizedHref = normalize(item.href);
|
||||
const isActive = !isExternal && (
|
||||
normalizedHref === '/'
|
||||
? normalizedCurrent === '/'
|
||||
: normalizedCurrent === normalizedHref || normalizedCurrent.startsWith(normalizedHref + '/')
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
class={isActive
|
||||
? "flex items-center gap-0.5 px-3 py-1.5 rounded-lg text-[13px] font-semibold transition-all duration-200 text-white bg-neutral-900 dark:bg-white dark:text-neutral-900 shadow-sm font-['IBM_Plex_Sans',sans-serif]"
|
||||
: "flex items-center gap-0.5 px-3 py-1.5 rounded-lg text-[13px] font-medium transition-all duration-200 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800/60 font-['IBM_Plex_Sans',sans-serif]"}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||
onclick={item.onclick}
|
||||
>
|
||||
{item.label}
|
||||
{item.icon === "external" && (
|
||||
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||||
)}
|
||||
{item.icon === "padlock" && (
|
||||
<span class="inline-flex" aria-hidden="true" set:html={padlockIconSvg}></span>
|
||||
)}
|
||||
{item.icon === "pirate" && (
|
||||
<span class="inline-flex ml-1" role="img" aria-label="Pirate flag">🏴☠️</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
aria-label="Toggle theme"
|
||||
type="button"
|
||||
class="flex items-center justify-center w-8 h-8 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
onclick="toggleTheme()"
|
||||
>
|
||||
<Icon
|
||||
name="fa6-solid:circle-half-stroke"
|
||||
class="h-4 w-4 text-[#1c1c1c] dark:text-[#D4D4D4]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mobile-nav flex items-center gap-2">
|
||||
<button
|
||||
aria-label="Toggle theme"
|
||||
type="button"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
onclick="toggleTheme()"
|
||||
>
|
||||
<Icon
|
||||
name="fa6-solid:circle-half-stroke"
|
||||
class="h-5 w-5 text-[#1c1c1c] dark:text-[#D4D4D4]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="mobile-menu-btn"
|
||||
aria-label="Toggle menu"
|
||||
type="button"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<svg id="menu-icon" class="w-6 h-6 text-neutral-700 dark:text-neutral-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
<svg id="close-icon" class="w-6 h-6 text-neutral-700 dark:text-neutral-300" style="display: none;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="mobile-menu"
|
||||
class="mobile-menu-dropdown md:hidden"
|
||||
>
|
||||
<ul class="flex flex-col gap-1 py-4">
|
||||
{visibleNavItems.map((item) => {
|
||||
const isExternal = item.href?.startsWith("http");
|
||||
const isAuthedPath = item.auth ?? false;
|
||||
const normalizedHref = normalize(item.href);
|
||||
const isActive = !isExternal && (
|
||||
normalizedHref === '/'
|
||||
? normalizedCurrent === '/'
|
||||
: normalizedCurrent === normalizedHref || normalizedCurrent.startsWith(normalizedHref + '/')
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
class={isActive
|
||||
? "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 text-white bg-neutral-900 dark:bg-white dark:text-neutral-900 font-['IBM_Plex_Sans',sans-serif]"
|
||||
: "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 font-['IBM_Plex_Sans',sans-serif]"}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||
onclick={item.onclick}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.icon === "external" && (
|
||||
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||||
)}
|
||||
{item.icon === "padlock" && (
|
||||
<span class="inline-flex" aria-hidden="true" set:html={padlockIconSvg}></span>
|
||||
)}
|
||||
{item.icon === "pirate" && (
|
||||
<span class="inline-flex ml-1" role="img" aria-label="Pirate flag">🏴☠️</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -1,17 +1,357 @@
|
||||
import { defineMiddleware } from 'astro:middleware';
|
||||
import { SUBSITES, PROTECTED_ROUTES, PUBLIC_ROUTES } from './config.js';
|
||||
import { getSubsiteByHost, getSubsiteFromSignal } from './utils/subsites.js';
|
||||
|
||||
const API_URL = "https://api.codey.lol";
|
||||
|
||||
// Auth check function (mirrors requireAuthHook logic but for middleware)
|
||||
async function checkAuth(request) {
|
||||
try {
|
||||
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`, {
|
||||
headers: { Cookie: cookieHeader },
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (import.meta.env.DEV) console.log(`[middleware:checkAuth] Initial auth/id status: ${res.status}`);
|
||||
|
||||
if (res.status === 401) {
|
||||
// Try refresh
|
||||
const refreshRes = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: cookieHeader },
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!refreshRes.ok) {
|
||||
return { authenticated: false, user: null, cookies: null };
|
||||
}
|
||||
|
||||
// Get refreshed cookies
|
||||
let setCookies = [];
|
||||
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) {
|
||||
return { authenticated: false, user: null, cookies: null };
|
||||
}
|
||||
|
||||
// Build new cookie header for retry
|
||||
const newCookieHeader = setCookies.map(c => c.split(";")[0].trim()).join("; ");
|
||||
|
||||
res = await fetch(`${API_URL}/auth/id`, {
|
||||
headers: { Cookie: newCookieHeader },
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return { authenticated: false, user: null, cookies: null };
|
||||
}
|
||||
|
||||
const user = await res.json();
|
||||
return { authenticated: true, user, cookies: setCookies };
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return { authenticated: false, user: null, cookies: null };
|
||||
}
|
||||
|
||||
const user = await res.json();
|
||||
return { authenticated: true, user, cookies: null };
|
||||
} catch (err) {
|
||||
console.error("[middleware] Auth check error:", err);
|
||||
return { authenticated: false, user: null, cookies: null };
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a path matches any protected route and return the config
|
||||
function getProtectedRouteConfig(pathname) {
|
||||
// Normalize pathname for comparison (lowercase)
|
||||
const normalizedPath = pathname.toLowerCase();
|
||||
|
||||
for (const route of PROTECTED_ROUTES) {
|
||||
const routePath = typeof route === 'string' ? route : route.path;
|
||||
const normalizedRoute = routePath.toLowerCase();
|
||||
const matches = normalizedPath === normalizedRoute || normalizedPath.startsWith(normalizedRoute + '/');
|
||||
if (matches) {
|
||||
return typeof route === 'string' ? { path: route, roles: null } : route;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if a path is explicitly public
|
||||
function isPublicRoute(pathname) {
|
||||
for (const route of PUBLIC_ROUTES) {
|
||||
// For routes ending with /, match any path starting with that prefix
|
||||
if (route.endsWith('/') && route !== '/') {
|
||||
if (pathname.startsWith(route)) {
|
||||
if (import.meta.env.DEV) console.log(`[middleware] isPublicRoute: ${pathname} matched ${route} (prefix)`);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// For other routes, match exactly or as a path prefix (route + /)
|
||||
if (pathname === route) {
|
||||
if (import.meta.env.DEV) console.log(`[middleware] isPublicRoute: ${pathname} matched ${route} (exact)`);
|
||||
return true;
|
||||
}
|
||||
// Special case: don't treat / as a prefix for all paths
|
||||
if (route !== '/' && pathname.startsWith(route + '/')) {
|
||||
if (import.meta.env.DEV) console.log(`[middleware] isPublicRoute: ${pathname} matched ${route}/ (subpath)`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (import.meta.env.DEV) console.log(`[middleware] isPublicRoute(${pathname}) = false`);
|
||||
return false;
|
||||
}
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
try {
|
||||
const pathname = context.url.pathname;
|
||||
|
||||
// Skip auth check for static assets and API routes
|
||||
const skipAuthPrefixes = ['/_astro', '/_', '/assets', '/scripts', '/favicon', '/images', '/_static'];
|
||||
const shouldSkipAuth = skipAuthPrefixes.some(p => pathname.startsWith(p));
|
||||
|
||||
// Check authentication for protected routes
|
||||
const protectedConfig = getProtectedRouteConfig(pathname);
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Path: ${pathname}, Protected: ${!!protectedConfig}, SkipAuth: ${shouldSkipAuth}`);
|
||||
|
||||
if (!shouldSkipAuth && protectedConfig && !isPublicRoute(pathname)) {
|
||||
const { authenticated, user, cookies } = await checkAuth(context.request);
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Auth result: authenticated=${authenticated}`);
|
||||
|
||||
if (!authenticated) {
|
||||
// Redirect to login with return URL
|
||||
const returnUrl = encodeURIComponent(pathname + context.url.search);
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Auth required for ${pathname}, redirecting to login`);
|
||||
return context.redirect(`/login?returnUrl=${returnUrl}`, 302);
|
||||
}
|
||||
|
||||
// Check role-based access if roles are specified
|
||||
if (protectedConfig.roles && protectedConfig.roles.length > 0) {
|
||||
const userRoles = user?.roles || [];
|
||||
const hasRequiredRole = protectedConfig.roles.some(role => userRoles.includes(role));
|
||||
|
||||
if (!hasRequiredRole) {
|
||||
if (import.meta.env.DEV) console.log(`[middleware] User lacks required role for ${pathname}, redirecting to login`);
|
||||
return context.redirect('/login', 302);
|
||||
}
|
||||
}
|
||||
|
||||
// Store user in locals for downstream use
|
||||
context.locals.user = user;
|
||||
|
||||
// Forward any refreshed cookies
|
||||
if (cookies && cookies.length > 0) {
|
||||
context.locals.refreshedCookies = cookies;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Auth OK for ${pathname}, user: ${user?.username || user?.id || 'unknown'}`);
|
||||
}
|
||||
|
||||
// Check the Host header to differentiate subdomains
|
||||
// Build a headers map safely because Headers.get(':authority') throws
|
||||
const headersMap = {};
|
||||
for (const [k, v] of context.request.headers) {
|
||||
headersMap[k.toLowerCase()] = v;
|
||||
}
|
||||
const hostHeader = headersMap['host'] || '';
|
||||
// Node/http2 might store host as :authority (pseudo header); it appears under iteration as ':authority'
|
||||
const authorityHeader = headersMap[':authority'] || '';
|
||||
// Fallback to context.url.hostname if available (some environments populate it)
|
||||
const urlHost = context.url?.hostname || '';
|
||||
const host = (hostHeader || authorityHeader || urlHost).split(':')[0]; // normalize remove port
|
||||
|
||||
const requestIp = (headersMap['x-forwarded-for']?.split(',')[0]?.trim())
|
||||
|| headersMap['x-real-ip']
|
||||
|| headersMap['cf-connecting-ip']
|
||||
|| headersMap['forwarded']?.split(';').find(kv => kv.trim().startsWith('for='))?.split('=')[1]?.replace(/"/g, '')
|
||||
|| context.request.headers.get('x-client-ip')
|
||||
|| 'unknown';
|
||||
|
||||
// Cloudflare geo data (available in production)
|
||||
const cfCountry = headersMap['cf-ipcountry'] || null;
|
||||
const userAgent = headersMap['user-agent'] || null;
|
||||
|
||||
// Debug info for incoming requests
|
||||
if (import.meta.env.DEV) console.log(`[middleware] incoming: host=${hostHeader} ip=${requestIp} path=${context.url.pathname}`);
|
||||
// else console.log(`[middleware] request from ${requestIp}${cfCountry ? ` (${cfCountry})` : ''}${userAgent ? ` [${userAgent}]` : ''}: ${context.url.pathname}`);
|
||||
|
||||
// When the host header is missing, log all headers for debugging and
|
||||
// attempt to determine host from forwarded headers or a dev query header.
|
||||
if (!host) {
|
||||
if (import.meta.env.DEV) console.log('[middleware] WARNING: Host header missing. Dumping headers:');
|
||||
for (const [k, v] of context.request.headers) {
|
||||
if (import.meta.env.DEV) console.log(`[middleware] header: ${k}=${v}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if how the request says it wants the whitelabel
|
||||
const forwardedHost = headersMap['x-forwarded-host'] || '';
|
||||
const xWhitelabel = headersMap['x-whitelabel'] || '';
|
||||
const forceWhitelabelQuery = context.url.searchParams.get('whitelabel');
|
||||
|
||||
// Make whitelabel detection dynamic based on `SUBSITES` mapping and incoming signals
|
||||
// We'll detect by full host (host/forwarded/authority) or by short-name match
|
||||
const subsiteHosts = Object.keys(SUBSITES || {});
|
||||
|
||||
const hostSubsite = getSubsiteByHost(host);
|
||||
const forwardedSubsite = getSubsiteByHost(forwardedHost);
|
||||
const authoritySubsite = getSubsiteByHost(authorityHeader);
|
||||
const headerSignalSubsite = getSubsiteFromSignal(xWhitelabel);
|
||||
const querySignalSubsite = getSubsiteFromSignal(forceWhitelabelQuery);
|
||||
|
||||
const wantsSubsite = Boolean(hostSubsite || forwardedSubsite || authoritySubsite || headerSignalSubsite || querySignalSubsite);
|
||||
|
||||
// Use central SUBSITES mapping
|
||||
// import from config.js near top to ensure single source of truth
|
||||
// (we import lazily here for compatibility with middleware runtime)
|
||||
const subsites = SUBSITES || {};
|
||||
|
||||
// Check if the request matches a subsite
|
||||
const subsitePath = subsites[host];
|
||||
if (subsitePath) {
|
||||
const skipPrefixes = ['/_astro', '/_', '/assets', '/scripts', '/favicon', '/api', '/robots.txt', '/_static'];
|
||||
const shouldSkip = skipPrefixes.some((p) => context.url.pathname.startsWith(p));
|
||||
|
||||
if (!shouldSkip && !context.url.pathname.startsWith(subsitePath)) {
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Rewriting ${host} ${context.url.pathname} -> ${subsitePath}${context.url.pathname}`);
|
||||
context.url.pathname = `${subsitePath}${context.url.pathname}`;
|
||||
}
|
||||
} else {
|
||||
// If the path appears to be a subsite path (like /subsites/req) but the host isn't a subsite,
|
||||
// block so the main site doesn't accidentally serve that content.
|
||||
const allPaths = Object.values(subsites || {});
|
||||
const pathLooksLikeSubsite = allPaths.some((p) => context.url.pathname.startsWith(p));
|
||||
if (pathLooksLikeSubsite) {
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Blocking subsite path on main domain: ${host}${context.url.pathname}`);
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Block /subsites/req/* on main domain (codey.lol, local.codey.lol, etc)
|
||||
const isMainDomain = !wantsSubsite;
|
||||
if (isMainDomain && Object.values(subsites || {}).some(p => context.url.pathname.startsWith(p))) {
|
||||
// Immediately return a 404 for /req on the main domain
|
||||
if (Object.values(subsites || {}).includes(context.url.pathname)) {
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Blocking subsite root on main domain: ${hostHeader}${context.url.pathname}`);
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Blocking subsite wildcard path on main domain: ${hostHeader}${context.url.pathname}`);
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
// Explicitly handle the forceWhitelabelQuery to rewrite the path
|
||||
if (forceWhitelabelQuery) {
|
||||
const forced = getSubsiteFromSignal(forceWhitelabelQuery);
|
||||
const subsitePath = forced?.path || (`/${forceWhitelabelQuery}`.startsWith('/') ? `/${forceWhitelabelQuery}` : `/${forceWhitelabelQuery}`);
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Forcing whitelabel via query: ${forceWhitelabelQuery} -> rewrite to ${subsitePath}${context.url.pathname}`);
|
||||
context.url.pathname = `${subsitePath}${context.url.pathname}`;
|
||||
}
|
||||
|
||||
// Pass the whitelabel value explicitly to context.locals
|
||||
// set a normalized whitelabel short name if available
|
||||
const chosen = querySignalSubsite?.short || headerSignalSubsite?.short || hostSubsite?.short || forwardedSubsite?.short || authoritySubsite?.short || null;
|
||||
context.locals.whitelabel = chosen;
|
||||
// Also make it explicit whether this request maps to a subsite (any configured SUBSITES value)
|
||||
// Middleware already resolved `wantsSubsite` which is true if host/forwarded/authority/header/query indicate a subsite.
|
||||
// Expose a simple boolean so server-rendered pages/layouts or components can opt-out of loading/hydrating
|
||||
// heavyweight subsystems (like the AudioPlayer) when the request is for a subsite.
|
||||
context.locals.isSubsite = Boolean(wantsSubsite);
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Setting context.locals.whitelabel: ${context.locals.whitelabel}`);
|
||||
|
||||
// Final debug: show the final pathname we hand to Astro
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Final pathname: ${context.url.pathname}`);
|
||||
|
||||
// Let Astro handle the request first
|
||||
const response = await next();
|
||||
|
||||
// Add security headers to response
|
||||
const securityHeaders = new Headers(response.headers);
|
||||
|
||||
// If it's a 404, redirect to home
|
||||
if (response.status === 404) {
|
||||
console.log(`404 redirect: ${context.url.pathname} -> /`);
|
||||
return context.redirect('/', 302);
|
||||
// Prevent clickjacking
|
||||
securityHeaders.set('X-Frame-Options', 'SAMEORIGIN');
|
||||
|
||||
// Prevent MIME type sniffing
|
||||
securityHeaders.set('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
// XSS protection (legacy, but still useful)
|
||||
securityHeaders.set('X-XSS-Protection', '1; mode=block');
|
||||
|
||||
// Referrer policy - send origin only on cross-origin requests
|
||||
securityHeaders.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
|
||||
// HSTS - enforce HTTPS (only in production)
|
||||
if (!import.meta.env.DEV) {
|
||||
securityHeaders.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
return response;
|
||||
// Permissions policy - restrict sensitive APIs
|
||||
securityHeaders.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||
|
||||
// Content Security Policy - restrict resource loading
|
||||
// Note: 'unsafe-inline' and 'unsafe-eval' needed for React/Astro hydration
|
||||
// In production, consider using nonces or hashes for stricter CSP
|
||||
const cspDirectives = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.youtube.com https://www.youtube-nocookie.com https://s.ytimg.com",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com data:",
|
||||
"img-src 'self' data: blob: https: http:",
|
||||
"media-src 'self' blob: https:",
|
||||
"connect-src 'self' https://api.codey.lol https://*.codey.lol wss:",
|
||||
// Allow YouTube for video embeds
|
||||
"frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'self'",
|
||||
"upgrade-insecure-requests",
|
||||
].join('; ');
|
||||
securityHeaders.set('Content-Security-Policy', cspDirectives);
|
||||
|
||||
// Forward any refreshed auth cookies to the client
|
||||
if (context.locals.refreshedCookies && context.locals.refreshedCookies.length > 0) {
|
||||
const newResponse = new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: securityHeaders,
|
||||
});
|
||||
context.locals.refreshedCookies.forEach(cookie => {
|
||||
newResponse.headers.append('set-cookie', cookie.trim());
|
||||
});
|
||||
|
||||
// If it's a 404, redirect to home
|
||||
if (newResponse.status === 404 && !context.url.pathname.startsWith('/api/')) {
|
||||
if (import.meta.env.DEV) console.log(`404 redirect: ${context.url.pathname} -> /`);
|
||||
return context.redirect('/', 302);
|
||||
}
|
||||
|
||||
return newResponse;
|
||||
}
|
||||
|
||||
// If it's a 404, redirect to home
|
||||
if (response.status === 404 && !context.url.pathname.startsWith('/api/')) {
|
||||
if (import.meta.env.DEV) console.log(`404 redirect: ${context.url.pathname} -> /`);
|
||||
return context.redirect('/', 302);
|
||||
}
|
||||
|
||||
// Return response with security headers
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: securityHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle any middleware errors by redirecting to home
|
||||
console.error('Middleware error:', error);
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
---
|
||||
import Base from "@/layouts/Base.astro";
|
||||
import Root from "@/components/AppLayout.jsx";
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
|
||||
const user = await requireAuthHook(Astro);
|
||||
|
||||
if (!user) {
|
||||
return Astro.redirect('/login');
|
||||
}
|
||||
|
||||
// Auth is handled by middleware - user available in Astro.locals.user
|
||||
// Middleware redirects to /login if not authenticated
|
||||
const user = Astro.locals.user as any;
|
||||
---
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="qs2.MediaRequestForm" client:only="react">
|
||||
</Root>
|
||||
<section class="page-section trip-section">
|
||||
<Root child="qs2.MediaRequestForm" client:only="react" />
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style is:global>
|
||||
/* Override main container width for TRip pages */
|
||||
body:has(.trip-section) main.page-enter {
|
||||
max-width: 1400px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
---
|
||||
import Base from "@/layouts/Base.astro";
|
||||
import Root from "@/components/AppLayout.jsx";
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
|
||||
const user = await requireAuthHook(Astro);
|
||||
|
||||
if (!user) {
|
||||
return Astro.redirect('/login');
|
||||
}
|
||||
|
||||
// Auth is handled by middleware - user available in Astro.locals.user
|
||||
// Middleware redirects to /login if not authenticated
|
||||
const user = Astro.locals.user as any;
|
||||
---
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="qs2.RequestManagement" client:only="react">
|
||||
</Root>
|
||||
<section class="page-section trip-section">
|
||||
<Root child="qs2.RequestManagement" client:only="react" />
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style is:global>
|
||||
/* Override main container width for TRip pages */
|
||||
html:has(.trip-section) main.page-enter {
|
||||
max-width: 1400px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
132
src/pages/api/discord/cached-image.js
Normal file
132
src/pages/api/discord/cached-image.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* API endpoint to serve cached images from the database
|
||||
* Serves avatars, emojis, attachments, etc. from local cache
|
||||
*
|
||||
* Security: Uses HMAC signatures to prevent enumeration of image IDs.
|
||||
* Images can only be accessed with a valid signature generated server-side.
|
||||
*/
|
||||
import sql from '../../../utils/db.js';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
} from '../../../utils/rateLimit.js';
|
||||
|
||||
// Secret for signing image IDs - prevents enumeration attacks
|
||||
const IMAGE_CACHE_SECRET = import.meta.env.IMAGE_CACHE_SECRET;
|
||||
if (!IMAGE_CACHE_SECRET) {
|
||||
console.error('CRITICAL: IMAGE_CACHE_SECRET environment variable is not set!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HMAC signature for an image ID
|
||||
* @param {string|number} imageId - The image ID to sign
|
||||
* @returns {string} - The hex signature
|
||||
*/
|
||||
export function signImageId(imageId) {
|
||||
if (!IMAGE_CACHE_SECRET) {
|
||||
throw new Error('IMAGE_CACHE_SECRET not configured');
|
||||
}
|
||||
const hmac = crypto.createHmac('sha256', IMAGE_CACHE_SECRET);
|
||||
hmac.update(String(imageId));
|
||||
return hmac.digest('hex').substring(0, 16); // Short signature is sufficient
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify HMAC signature for an image ID
|
||||
* @param {string|number} imageId - The image ID
|
||||
* @param {string} signature - The signature to verify
|
||||
* @returns {boolean} - Whether signature is valid
|
||||
*/
|
||||
function verifyImageSignature(imageId, signature) {
|
||||
if (!IMAGE_CACHE_SECRET || !signature) return false;
|
||||
const expected = signImageId(imageId);
|
||||
// Timing-safe comparison
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit check - higher limit for images but still protected
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 100,
|
||||
windowMs: 1000,
|
||||
burstLimit: 500,
|
||||
burstWindowMs: 10_000,
|
||||
});
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
return new Response('Rate limit exceeded', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '1' },
|
||||
});
|
||||
}
|
||||
|
||||
recordRequest(request, 1000);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const imageId = url.searchParams.get('id');
|
||||
const signature = url.searchParams.get('sig');
|
||||
const sourceUrl = url.searchParams.get('url');
|
||||
|
||||
if (!imageId && !sourceUrl) {
|
||||
return new Response('Missing id or url parameter', { status: 400 });
|
||||
}
|
||||
|
||||
// Validate imageId is a valid integer if provided
|
||||
if (imageId && !/^\d+$/.test(imageId)) {
|
||||
return new Response('Invalid image id', { status: 400 });
|
||||
}
|
||||
|
||||
// Require valid signature for ID-based lookups to prevent enumeration
|
||||
if (imageId && !verifyImageSignature(imageId, signature)) {
|
||||
return new Response('Invalid or missing signature', { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
let image;
|
||||
|
||||
if (imageId) {
|
||||
// Look up by image_id (signature already verified above)
|
||||
const result = await sql`
|
||||
SELECT image_data, content_type, source_url
|
||||
FROM image_cache
|
||||
WHERE image_id = ${imageId}
|
||||
`;
|
||||
image = result[0];
|
||||
} else {
|
||||
// Look up by source_url - no signature needed as URL itself is the identifier
|
||||
const result = await sql`
|
||||
SELECT image_data, content_type, source_url
|
||||
FROM image_cache
|
||||
WHERE source_url = ${sourceUrl}
|
||||
`;
|
||||
image = result[0];
|
||||
}
|
||||
|
||||
if (!image) {
|
||||
return new Response('Image not found in cache', { status: 404 });
|
||||
}
|
||||
|
||||
// image_data is a Buffer (bytea)
|
||||
const imageBuffer = image.image_data;
|
||||
const contentType = image.content_type || 'image/png';
|
||||
|
||||
return new Response(imageBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': imageBuffer.length.toString(),
|
||||
'Cache-Control': 'public, max-age=31536000, immutable', // Cache for 1 year since it's immutable
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error serving cached image:', error);
|
||||
return new Response('Failed to serve image', { status: 500 });
|
||||
}
|
||||
}
|
||||
128
src/pages/api/discord/channels.js
Normal file
128
src/pages/api/discord/channels.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* API endpoint to fetch Discord channels from database
|
||||
*/
|
||||
import sql from '../../../utils/db.js';
|
||||
import { requireApiAuth, createApiResponse } from '../../../utils/apiAuth.js';
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
} from '../../../utils/rateLimit.js';
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit check
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 20,
|
||||
windowMs: 1000,
|
||||
burstLimit: 100,
|
||||
burstWindowMs: 10_000,
|
||||
});
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
return new Response(JSON.stringify({
|
||||
error: rateCheck.isFlooding ? 'Too many requests - please slow down' : 'Rate limit exceeded'
|
||||
}), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||
});
|
||||
}
|
||||
|
||||
recordRequest(request, 1000);
|
||||
|
||||
// Check authentication
|
||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
// Helper to validate Discord snowflake IDs (17-20 digit strings)
|
||||
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const guildId = url.searchParams.get('guildId');
|
||||
|
||||
// Validate snowflake format
|
||||
if (!isValidSnowflake(guildId)) {
|
||||
return createApiResponse({ error: 'Invalid guildId format' }, 400);
|
||||
}
|
||||
|
||||
let channels;
|
||||
|
||||
// Channel types: 0=text, 2=voice, 4=category, 5=announcement, 10/11/12=threads
|
||||
// Fetch text channels (type 0), categories (type 4), and threads (types 10, 11, 12)
|
||||
|
||||
if (guildId) {
|
||||
// Fetch channels for specific guild with message counts
|
||||
// Use LEFT JOIN with aggregated counts to avoid N+1 subqueries
|
||||
channels = await sql`
|
||||
WITH channel_counts AS (
|
||||
SELECT channel_id, COUNT(*) as message_count
|
||||
FROM messages
|
||||
WHERE is_deleted = FALSE
|
||||
GROUP BY channel_id
|
||||
)
|
||||
SELECT
|
||||
c.channel_id,
|
||||
c.name,
|
||||
c.type,
|
||||
c.position,
|
||||
c.parent_id,
|
||||
c.topic,
|
||||
c.guild_id,
|
||||
g.name as guild_name,
|
||||
g.icon_url as guild_icon,
|
||||
COALESCE(cc.message_count, 0) as message_count
|
||||
FROM channels c
|
||||
LEFT JOIN guilds g ON c.guild_id = g.guild_id
|
||||
LEFT JOIN channel_counts cc ON c.channel_id = cc.channel_id
|
||||
WHERE c.guild_id = ${guildId}
|
||||
AND c.type IN (0, 4, 10, 11, 12)
|
||||
ORDER BY c.position ASC, c.name ASC
|
||||
`;
|
||||
} else {
|
||||
// Fetch all text channels, categories, and threads with message counts
|
||||
// Use LEFT JOIN with aggregated counts to avoid N+1 subqueries
|
||||
channels = await sql`
|
||||
WITH channel_counts AS (
|
||||
SELECT channel_id, COUNT(*) as message_count
|
||||
FROM messages
|
||||
WHERE is_deleted = FALSE
|
||||
GROUP BY channel_id
|
||||
)
|
||||
SELECT
|
||||
c.channel_id,
|
||||
c.name,
|
||||
c.type,
|
||||
c.position,
|
||||
c.parent_id,
|
||||
c.topic,
|
||||
c.guild_id,
|
||||
g.name as guild_name,
|
||||
g.icon_url as guild_icon,
|
||||
COALESCE(cc.message_count, 0) as message_count
|
||||
FROM channels c
|
||||
LEFT JOIN guilds g ON c.guild_id = g.guild_id
|
||||
LEFT JOIN channel_counts cc ON c.channel_id = cc.channel_id
|
||||
WHERE c.type IN (0, 4, 10, 11, 12)
|
||||
ORDER BY g.name ASC, c.position ASC, c.name ASC
|
||||
`;
|
||||
}
|
||||
|
||||
// Transform to expected format
|
||||
const formattedChannels = channels.map(ch => ({
|
||||
id: ch.channel_id.toString(),
|
||||
name: ch.name,
|
||||
type: ch.type,
|
||||
position: ch.position,
|
||||
parentId: ch.parent_id?.toString() || null,
|
||||
topic: ch.topic || null,
|
||||
guildId: ch.guild_id?.toString() || null,
|
||||
guildName: ch.guild_name,
|
||||
guildIcon: ch.guild_icon,
|
||||
messageCount: parseInt(ch.message_count) || 0,
|
||||
}));
|
||||
|
||||
return createApiResponse(formattedChannels, 200, setCookieHeader);
|
||||
} catch (error) {
|
||||
console.error('Error fetching channels:', error);
|
||||
return createApiResponse({ error: 'Failed to fetch channels' }, 500, setCookieHeader);
|
||||
}
|
||||
}
|
||||
332
src/pages/api/discord/members.js
Normal file
332
src/pages/api/discord/members.js
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* API endpoint to fetch Discord guild members with roles
|
||||
* Optionally filters by channel visibility using permission overwrites
|
||||
*/
|
||||
import sql from '../../../utils/db.js';
|
||||
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
} from '../../../utils/rateLimit.js';
|
||||
import { signImageId } from './cached-image.js';
|
||||
|
||||
// Discord permission flags
|
||||
const VIEW_CHANNEL = 0x400n; // 1024
|
||||
const ADMINISTRATOR = 0x8n; // 8
|
||||
|
||||
/**
|
||||
* Calculate which members can view a specific channel
|
||||
* Emulates Discord's permission calculation:
|
||||
* 1. Start with @everyone role permissions
|
||||
* 2. Apply role-based overwrites (allow/deny)
|
||||
* 3. Apply member-specific overwrites (allow/deny)
|
||||
* 4. Check if VIEW_CHANNEL permission is granted
|
||||
*/
|
||||
async function getChannelVisibleMembers(channelId, guildId) {
|
||||
// Get guild info including owner
|
||||
const guildInfo = await sql`
|
||||
SELECT owner_id FROM guilds WHERE guild_id = ${guildId}
|
||||
`;
|
||||
const ownerId = guildInfo[0]?.owner_id;
|
||||
|
||||
// Get @everyone role (same ID as guild)
|
||||
const everyoneRole = await sql`
|
||||
SELECT role_id, permissions FROM roles WHERE role_id = ${guildId}
|
||||
`;
|
||||
const basePermissions = everyoneRole[0]?.permissions ? BigInt(everyoneRole[0].permissions) : 0n;
|
||||
|
||||
// Get all channel permission overwrites
|
||||
const overwrites = await sql`
|
||||
SELECT target_id, target_type, allow_permissions, deny_permissions
|
||||
FROM channel_permission_overwrites
|
||||
WHERE channel_id = ${channelId}
|
||||
`;
|
||||
|
||||
// Build overwrite lookups
|
||||
const roleOverwrites = new Map();
|
||||
const memberOverwrites = new Map();
|
||||
for (const ow of overwrites) {
|
||||
const targetId = ow.target_id.toString();
|
||||
const allow = BigInt(ow.allow_permissions);
|
||||
const deny = BigInt(ow.deny_permissions);
|
||||
if (ow.target_type === 'role') {
|
||||
roleOverwrites.set(targetId, { allow, deny });
|
||||
} else {
|
||||
memberOverwrites.set(targetId, { allow, deny });
|
||||
}
|
||||
}
|
||||
|
||||
// Get all guild members with their roles
|
||||
const members = await sql`
|
||||
SELECT gm.user_id, gm.roles
|
||||
FROM guild_members gm
|
||||
WHERE gm.guild_id = ${guildId}
|
||||
`;
|
||||
|
||||
// Get all role permissions for the guild
|
||||
const allRoles = await sql`
|
||||
SELECT role_id, permissions FROM roles WHERE guild_id = ${guildId}
|
||||
`;
|
||||
const rolePermissions = new Map();
|
||||
for (const role of allRoles) {
|
||||
rolePermissions.set(role.role_id.toString(), BigInt(role.permissions || 0));
|
||||
}
|
||||
|
||||
const visibleMemberIds = new Set();
|
||||
|
||||
for (const member of members) {
|
||||
const userId = member.user_id.toString();
|
||||
const memberRoles = (member.roles || []).map(r => r.toString());
|
||||
|
||||
// Owner always has access
|
||||
if (ownerId && member.user_id.toString() === ownerId.toString()) {
|
||||
visibleMemberIds.add(userId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if any role has ADMINISTRATOR
|
||||
let isAdmin = false;
|
||||
for (const roleId of memberRoles) {
|
||||
const perms = rolePermissions.get(roleId) || 0n;
|
||||
if (perms & ADMINISTRATOR) {
|
||||
isAdmin = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
visibleMemberIds.add(userId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate effective permissions
|
||||
let permissions = basePermissions;
|
||||
|
||||
// Apply @everyone role overwrite first
|
||||
const everyoneOverwrite = roleOverwrites.get(guildId.toString());
|
||||
if (everyoneOverwrite) {
|
||||
permissions = (permissions & ~everyoneOverwrite.deny) | everyoneOverwrite.allow;
|
||||
}
|
||||
|
||||
// Apply role overwrites (combined)
|
||||
let allowCombined = 0n;
|
||||
let denyCombined = 0n;
|
||||
for (const roleId of memberRoles) {
|
||||
const ow = roleOverwrites.get(roleId);
|
||||
if (ow) {
|
||||
allowCombined |= ow.allow;
|
||||
denyCombined |= ow.deny;
|
||||
}
|
||||
}
|
||||
permissions = (permissions & ~denyCombined) | allowCombined;
|
||||
|
||||
// Apply member-specific overwrite (highest priority)
|
||||
const memberOverwrite = memberOverwrites.get(userId);
|
||||
if (memberOverwrite) {
|
||||
permissions = (permissions & ~memberOverwrite.deny) | memberOverwrite.allow;
|
||||
}
|
||||
|
||||
// Check VIEW_CHANNEL permission
|
||||
if (permissions & VIEW_CHANNEL) {
|
||||
visibleMemberIds.add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
return visibleMemberIds;
|
||||
}
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit check
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 20,
|
||||
windowMs: 1000,
|
||||
burstLimit: 100,
|
||||
burstWindowMs: 10_000,
|
||||
});
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
return new Response(JSON.stringify({
|
||||
error: rateCheck.isFlooding ? 'Too many requests - please slow down' : 'Rate limit exceeded'
|
||||
}), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||
});
|
||||
}
|
||||
|
||||
recordRequest(request, 1000);
|
||||
|
||||
// Check authentication
|
||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
// Helper to create responses with optional Set-Cookie header
|
||||
const createResponse = (data, status = 200) => new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(setCookieHeader && { 'Set-Cookie': setCookieHeader }),
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to validate Discord snowflake IDs (17-20 digit strings)
|
||||
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const guildId = url.searchParams.get('guildId');
|
||||
const channelId = url.searchParams.get('channelId'); // Optional: filter by channel visibility
|
||||
|
||||
if (!guildId) {
|
||||
return createResponse({ error: 'guildId is required' }, 400);
|
||||
}
|
||||
|
||||
// Validate snowflake formats
|
||||
if (!isValidSnowflake(guildId) || !isValidSnowflake(channelId)) {
|
||||
return createResponse({ error: 'Invalid ID format' }, 400);
|
||||
}
|
||||
|
||||
// If channelId provided, get visible members for that channel
|
||||
let visibleMemberIds = null;
|
||||
if (channelId) {
|
||||
visibleMemberIds = await getChannelVisibleMembers(channelId, guildId);
|
||||
}
|
||||
|
||||
// Fetch all members for the guild with their user info
|
||||
const members = await sql`
|
||||
SELECT
|
||||
gm.user_id,
|
||||
gm.nickname,
|
||||
gm.roles,
|
||||
gm.guild_avatar_url,
|
||||
gm.cached_guild_avatar_id,
|
||||
u.username,
|
||||
u.display_name,
|
||||
u.global_name,
|
||||
u.avatar_url,
|
||||
u.cached_avatar_id,
|
||||
u.is_bot
|
||||
FROM guild_members gm
|
||||
JOIN users u ON gm.user_id = u.user_id
|
||||
WHERE gm.guild_id = ${guildId}
|
||||
ORDER BY u.username ASC
|
||||
`;
|
||||
|
||||
// Fetch all roles for the guild
|
||||
const roles = await sql`
|
||||
SELECT
|
||||
role_id,
|
||||
name,
|
||||
color,
|
||||
position,
|
||||
hoist
|
||||
FROM roles
|
||||
WHERE guild_id = ${guildId}
|
||||
ORDER BY position DESC
|
||||
`;
|
||||
|
||||
// Create role lookup map
|
||||
const roleMap = {};
|
||||
roles.forEach(role => {
|
||||
roleMap[role.role_id.toString()] = {
|
||||
id: role.role_id.toString(),
|
||||
name: role.name,
|
||||
color: role.color ? `#${role.color.toString(16).padStart(6, '0')}` : null,
|
||||
position: role.position,
|
||||
hoist: role.hoist, // "hoist" means the role is displayed separately in member list
|
||||
};
|
||||
});
|
||||
|
||||
// Filter members by channel visibility if channelId was provided
|
||||
const filteredMembers = visibleMemberIds
|
||||
? members.filter(m => visibleMemberIds.has(m.user_id.toString()))
|
||||
: members;
|
||||
|
||||
// Format members with their highest role for color
|
||||
const formattedMembers = filteredMembers.map(member => {
|
||||
const memberRoles = (member.roles || []).map(r => r.toString());
|
||||
|
||||
// Find highest positioned role with color for display
|
||||
let displayRole = null;
|
||||
let highestPosition = -1;
|
||||
|
||||
memberRoles.forEach(roleId => {
|
||||
const role = roleMap[roleId];
|
||||
if (role && role.position > highestPosition) {
|
||||
highestPosition = role.position;
|
||||
if (role.color && role.color !== '#000000') {
|
||||
displayRole = role;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Find all hoisted roles for grouping
|
||||
const hoistedRoles = memberRoles
|
||||
.map(rid => roleMap[rid])
|
||||
.filter(r => r && r.hoist)
|
||||
.sort((a, b) => b.position - a.position);
|
||||
|
||||
const primaryRole = hoistedRoles[0] || displayRole || null;
|
||||
|
||||
// Prefer cached avatar, then guild avatar, then user avatar
|
||||
const cachedAvatarId = member.cached_guild_avatar_id || member.cached_avatar_id;
|
||||
let avatarUrl;
|
||||
if (cachedAvatarId) {
|
||||
const sig = signImageId(cachedAvatarId);
|
||||
avatarUrl = `/api/discord/cached-image?id=${cachedAvatarId}&sig=${sig}`;
|
||||
} else {
|
||||
avatarUrl = member.guild_avatar_url || member.avatar_url;
|
||||
}
|
||||
|
||||
return {
|
||||
id: member.user_id.toString(),
|
||||
username: member.username,
|
||||
displayName: member.nickname || member.global_name || member.username,
|
||||
avatar: avatarUrl,
|
||||
isBot: member.is_bot || false,
|
||||
color: displayRole?.color || null,
|
||||
primaryRoleId: primaryRole?.id || null,
|
||||
primaryRoleName: primaryRole?.name || null,
|
||||
roles: memberRoles,
|
||||
};
|
||||
});
|
||||
|
||||
// Group members by their primary (highest hoisted) role
|
||||
const membersByRole = {};
|
||||
const noRoleMembers = [];
|
||||
|
||||
formattedMembers.forEach(member => {
|
||||
if (member.primaryRoleId) {
|
||||
if (!membersByRole[member.primaryRoleId]) {
|
||||
membersByRole[member.primaryRoleId] = {
|
||||
role: roleMap[member.primaryRoleId],
|
||||
members: [],
|
||||
};
|
||||
}
|
||||
membersByRole[member.primaryRoleId].members.push(member);
|
||||
} else {
|
||||
noRoleMembers.push(member);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort groups by role position (highest first)
|
||||
const sortedGroups = Object.values(membersByRole)
|
||||
.sort((a, b) => b.role.position - a.role.position);
|
||||
|
||||
// Add "Online" or default group for members without hoisted roles
|
||||
if (noRoleMembers.length > 0) {
|
||||
sortedGroups.push({
|
||||
role: { id: 'none', name: 'Members', color: null, position: -1 },
|
||||
members: noRoleMembers,
|
||||
});
|
||||
}
|
||||
|
||||
return createResponse({
|
||||
roles: Object.values(roleMap).sort((a, b) => b.position - a.position),
|
||||
groups: sortedGroups,
|
||||
totalMembers: formattedMembers.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching members:', error);
|
||||
return createResponse({ error: 'Failed to fetch members' }, 500);
|
||||
}
|
||||
}
|
||||
916
src/pages/api/discord/messages.js
Normal file
916
src/pages/api/discord/messages.js
Normal file
@@ -0,0 +1,916 @@
|
||||
/**
|
||||
* API endpoint to fetch Discord messages from database
|
||||
* Includes user info, attachments, embeds, and reactions
|
||||
*/
|
||||
import sql from '../../../utils/db.js';
|
||||
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
} from '../../../utils/rateLimit.js';
|
||||
import { signImageId } from './cached-image.js';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const IMAGE_PROXY_SECRET = import.meta.env.IMAGE_PROXY_SECRET;
|
||||
if (!IMAGE_PROXY_SECRET) {
|
||||
console.error('WARNING: IMAGE_PROXY_SECRET not set, image signing will fail');
|
||||
}
|
||||
|
||||
// Trusted domains that don't need proxying
|
||||
const TRUSTED_DOMAINS = new Set([
|
||||
'cdn.discordapp.com',
|
||||
'media.discordapp.net',
|
||||
'i.imgur.com',
|
||||
'imgur.com',
|
||||
'i.redd.it',
|
||||
'preview.redd.it',
|
||||
'youtube.com',
|
||||
'i.ytimg.com',
|
||||
'img.youtube.com',
|
||||
'youtu.be',
|
||||
'twitter.com',
|
||||
'pbs.twimg.com',
|
||||
'abs.twimg.com',
|
||||
'instagram.com',
|
||||
'github.com',
|
||||
'raw.githubusercontent.com',
|
||||
'avatars.githubusercontent.com',
|
||||
'user-images.githubusercontent.com',
|
||||
]);
|
||||
|
||||
function isTrustedDomain(url) {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
return TRUSTED_DOMAINS.has(hostname) ||
|
||||
Array.from(TRUSTED_DOMAINS).some(domain => hostname.endsWith('.' + domain));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function generateSignature(url) {
|
||||
return crypto
|
||||
.createHmac('sha256', IMAGE_PROXY_SECRET)
|
||||
.update(url)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best URL for an image - prefer cached version if available
|
||||
* @param {number|null} cachedImageId - The cached image ID from database
|
||||
* @param {string|null} originalUrl - The original CDN URL
|
||||
* @param {string} baseUrl - Base URL for constructing cached image URLs
|
||||
* @returns {string|null} The URL to use
|
||||
*/
|
||||
function getCachedOrProxyUrl(cachedImageId, originalUrl, baseUrl) {
|
||||
// Prefer cached image if available
|
||||
if (cachedImageId) {
|
||||
const sig = signImageId(cachedImageId);
|
||||
return `${baseUrl}/api/discord/cached-image?id=${cachedImageId}&sig=${sig}`;
|
||||
}
|
||||
|
||||
// Fall back to original URL with proxy if needed
|
||||
if (!originalUrl) return null;
|
||||
|
||||
if (isTrustedDomain(originalUrl)) {
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
const signature = generateSignature(originalUrl);
|
||||
const encodedUrl = encodeURIComponent(originalUrl);
|
||||
return `${baseUrl}/api/image-proxy?url=${encodedUrl}&sig=${signature}`;
|
||||
}
|
||||
|
||||
function getSafeImageUrl(originalUrl, baseUrl) {
|
||||
if (!originalUrl) return null;
|
||||
|
||||
if (isTrustedDomain(originalUrl)) {
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
const signature = generateSignature(originalUrl);
|
||||
const encodedUrl = encodeURIComponent(originalUrl);
|
||||
return `${baseUrl}/api/image-proxy?url=${encodedUrl}&sig=${signature}`;
|
||||
}
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit check
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 30,
|
||||
windowMs: 1000,
|
||||
burstLimit: 150,
|
||||
burstWindowMs: 10_000,
|
||||
});
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
return new Response(JSON.stringify({
|
||||
error: rateCheck.isFlooding ? 'Too many requests - please slow down' : 'Rate limit exceeded'
|
||||
}), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||
});
|
||||
}
|
||||
|
||||
recordRequest(request, 1000);
|
||||
|
||||
// Check authentication
|
||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
// Helper to create responses with optional Set-Cookie header
|
||||
const createResponse = (data, status = 200) => new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(setCookieHeader && { 'Set-Cookie': setCookieHeader }),
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to validate Discord snowflake IDs (17-20 digit strings)
|
||||
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const channelId = url.searchParams.get('channelId');
|
||||
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 100);
|
||||
const before = url.searchParams.get('before'); // For pagination
|
||||
const after = url.searchParams.get('after');
|
||||
const around = url.searchParams.get('around'); // For jumping to specific message
|
||||
const editedSince = url.searchParams.get('editedSince'); // ISO timestamp for fetching recently edited messages
|
||||
|
||||
const baseUrl = `${url.protocol}//${url.host}`;
|
||||
|
||||
if (!channelId) {
|
||||
return createResponse({ error: 'channelId is required' }, 400);
|
||||
}
|
||||
|
||||
// Validate snowflake formats
|
||||
if (!isValidSnowflake(channelId) || !isValidSnowflake(before) || !isValidSnowflake(after) || !isValidSnowflake(around)) {
|
||||
return createResponse({ error: 'Invalid ID format' }, 400);
|
||||
}
|
||||
|
||||
// Get guild_id from channel
|
||||
const channelInfo = await sql`
|
||||
SELECT guild_id FROM channels WHERE channel_id = ${channelId}
|
||||
`;
|
||||
const guildId = channelInfo[0]?.guild_id;
|
||||
|
||||
// Build the query with optional pagination
|
||||
// Joins guild_members for nickname and gets highest role color
|
||||
let messages;
|
||||
|
||||
if (editedSince) {
|
||||
// Fetch messages edited since the given timestamp
|
||||
const editedSinceDate = new Date(editedSince);
|
||||
messages = await sql`
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.raw_data,
|
||||
m.message_type,
|
||||
m.content,
|
||||
m.created_at,
|
||||
m.edited_at,
|
||||
m.reference_message_id,
|
||||
m.channel_id,
|
||||
m.webhook_id,
|
||||
m.reference_guild_id,
|
||||
c.guild_id as message_guild_id,
|
||||
u.user_id as author_id,
|
||||
u.username as author_username,
|
||||
u.discriminator as author_discriminator,
|
||||
u.avatar_url as author_avatar,
|
||||
u.is_bot as author_bot,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as author_display_name,
|
||||
COALESCE(gm.guild_avatar_url, u.avatar_url) as author_guild_avatar,
|
||||
(
|
||||
SELECT r.color
|
||||
FROM roles r
|
||||
WHERE r.role_id = ANY(gm.roles)
|
||||
AND r.color IS NOT NULL
|
||||
AND r.color != 0
|
||||
ORDER BY r.position DESC
|
||||
LIMIT 1
|
||||
) as author_color
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON m.author_id = u.user_id
|
||||
LEFT JOIN channels c ON m.channel_id = c.channel_id
|
||||
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND c.guild_id = gm.guild_id
|
||||
WHERE m.channel_id = ${channelId}
|
||||
AND m.edited_at > ${editedSinceDate}
|
||||
AND m.is_deleted = FALSE
|
||||
ORDER BY m.edited_at DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
} else if (before) {
|
||||
messages = await sql`
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.raw_data,
|
||||
m.message_type,
|
||||
m.content,
|
||||
m.created_at,
|
||||
m.edited_at,
|
||||
m.reference_message_id,
|
||||
m.channel_id,
|
||||
u.user_id as author_id,
|
||||
u.username as author_username,
|
||||
u.discriminator as author_discriminator,
|
||||
u.avatar_url as author_avatar,
|
||||
u.is_bot as author_bot,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as author_display_name,
|
||||
COALESCE(gm.guild_avatar_url, u.avatar_url) as author_guild_avatar,
|
||||
(
|
||||
SELECT r.color
|
||||
FROM roles r
|
||||
WHERE r.role_id = ANY(gm.roles)
|
||||
AND r.color IS NOT NULL
|
||||
AND r.color != 0
|
||||
ORDER BY r.position DESC
|
||||
LIMIT 1
|
||||
) as author_color
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON m.author_id = u.user_id
|
||||
LEFT JOIN channels c ON m.channel_id = c.channel_id
|
||||
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND c.guild_id = gm.guild_id
|
||||
WHERE m.channel_id = ${channelId}
|
||||
AND m.message_id < ${before}
|
||||
AND m.is_deleted = FALSE
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
} else if (after) {
|
||||
messages = await sql`
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.raw_data,
|
||||
m.message_type,
|
||||
m.content,
|
||||
m.created_at,
|
||||
m.edited_at,
|
||||
m.reference_message_id,
|
||||
m.channel_id,
|
||||
m.webhook_id,
|
||||
m.reference_guild_id,
|
||||
c.guild_id as message_guild_id,
|
||||
u.user_id as author_id,
|
||||
u.username as author_username,
|
||||
u.discriminator as author_discriminator,
|
||||
u.avatar_url as author_avatar,
|
||||
u.is_bot as author_bot,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as author_display_name,
|
||||
COALESCE(gm.guild_avatar_url, u.avatar_url) as author_guild_avatar,
|
||||
(
|
||||
SELECT r.color
|
||||
FROM roles r
|
||||
WHERE r.role_id = ANY(gm.roles)
|
||||
AND r.color IS NOT NULL
|
||||
AND r.color != 0
|
||||
ORDER BY r.position DESC
|
||||
LIMIT 1
|
||||
) as author_color
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON m.author_id = u.user_id
|
||||
LEFT JOIN channels c ON m.channel_id = c.channel_id
|
||||
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND c.guild_id = gm.guild_id
|
||||
WHERE m.channel_id = ${channelId}
|
||||
AND m.message_id > ${after}
|
||||
AND m.is_deleted = FALSE
|
||||
ORDER BY m.created_at ASC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
} else if (around) {
|
||||
// Fetch messages around a specific message ID (half before, half after)
|
||||
const halfLimit = Math.floor(limit / 2);
|
||||
messages = await sql`
|
||||
(
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.raw_data,
|
||||
m.message_type,
|
||||
m.content,
|
||||
m.created_at,
|
||||
m.edited_at,
|
||||
m.reference_message_id,
|
||||
m.channel_id,
|
||||
m.webhook_id,
|
||||
m.reference_guild_id,
|
||||
c.guild_id as message_guild_id,
|
||||
u.user_id as author_id,
|
||||
u.username as author_username,
|
||||
u.discriminator as author_discriminator,
|
||||
u.avatar_url as author_avatar,
|
||||
u.is_bot as author_bot,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as author_display_name,
|
||||
COALESCE(gm.guild_avatar_url, u.avatar_url) as author_guild_avatar,
|
||||
(
|
||||
SELECT r.color
|
||||
FROM roles r
|
||||
WHERE r.role_id = ANY(gm.roles)
|
||||
AND r.color IS NOT NULL
|
||||
AND r.color != 0
|
||||
ORDER BY r.position DESC
|
||||
LIMIT 1
|
||||
) as author_color
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON m.author_id = u.user_id
|
||||
LEFT JOIN channels c ON m.channel_id = c.channel_id
|
||||
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND c.guild_id = gm.guild_id
|
||||
WHERE m.channel_id = ${channelId}
|
||||
AND m.message_id <= ${around}
|
||||
AND m.is_deleted = FALSE
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT ${halfLimit + 1}
|
||||
)
|
||||
UNION ALL
|
||||
(
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.message_type,
|
||||
m.content,
|
||||
m.created_at,
|
||||
m.edited_at,
|
||||
m.reference_message_id,
|
||||
m.channel_id,
|
||||
m.webhook_id,
|
||||
m.reference_guild_id,
|
||||
c.guild_id as message_guild_id,
|
||||
u.user_id as author_id,
|
||||
u.username as author_username,
|
||||
u.discriminator as author_discriminator,
|
||||
u.avatar_url as author_avatar,
|
||||
u.is_bot as author_bot,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as author_display_name,
|
||||
COALESCE(gm.guild_avatar_url, u.avatar_url) as author_guild_avatar,
|
||||
(
|
||||
SELECT r.color
|
||||
FROM roles r
|
||||
WHERE r.role_id = ANY(gm.roles)
|
||||
AND r.color IS NOT NULL
|
||||
AND r.color != 0
|
||||
ORDER BY r.position DESC
|
||||
LIMIT 1
|
||||
) as author_color
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON m.author_id = u.user_id
|
||||
LEFT JOIN channels c ON m.channel_id = c.channel_id
|
||||
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND c.guild_id = gm.guild_id
|
||||
WHERE m.channel_id = ${channelId}
|
||||
AND m.message_id > ${around}
|
||||
AND m.is_deleted = FALSE
|
||||
ORDER BY m.created_at ASC
|
||||
LIMIT ${halfLimit}
|
||||
)
|
||||
ORDER BY created_at ASC
|
||||
`;
|
||||
} else {
|
||||
messages = await sql`
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.raw_data,
|
||||
m.message_type,
|
||||
m.content,
|
||||
m.created_at,
|
||||
m.edited_at,
|
||||
m.reference_message_id,
|
||||
m.channel_id,
|
||||
m.webhook_id,
|
||||
m.reference_guild_id,
|
||||
c.guild_id as message_guild_id,
|
||||
u.user_id as author_id,
|
||||
u.username as author_username,
|
||||
u.discriminator as author_discriminator,
|
||||
u.avatar_url as author_avatar,
|
||||
u.is_bot as author_bot,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as author_display_name,
|
||||
COALESCE(gm.guild_avatar_url, u.avatar_url) as author_guild_avatar,
|
||||
(
|
||||
SELECT r.color
|
||||
FROM roles r
|
||||
WHERE r.role_id = ANY(gm.roles)
|
||||
AND r.color IS NOT NULL
|
||||
AND r.color != 0
|
||||
ORDER BY r.position DESC
|
||||
LIMIT 1
|
||||
) as author_color
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON m.author_id = u.user_id
|
||||
LEFT JOIN channels c ON m.channel_id = c.channel_id
|
||||
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND c.guild_id = gm.guild_id
|
||||
WHERE m.channel_id = ${channelId}
|
||||
AND m.is_deleted = FALSE
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
}
|
||||
|
||||
// Messages are already in DESC order (newest first) - no need to reverse
|
||||
|
||||
if (messages.length === 0) {
|
||||
return createResponse({ messages: [], users: {} });
|
||||
}
|
||||
|
||||
// Get all message IDs for fetching related data
|
||||
const messageIds = messages.map(m => m.message_id);
|
||||
|
||||
// Get unique author IDs for fetching cached avatar info
|
||||
const authorIds = [...new Set(messages.map(m => m.author_id).filter(Boolean))];
|
||||
|
||||
// Fetch cached avatar IDs for all authors
|
||||
const userAvatarCache = {};
|
||||
if (authorIds.length > 0) {
|
||||
const avatarInfo = await sql`
|
||||
SELECT
|
||||
u.user_id,
|
||||
u.cached_avatar_id,
|
||||
gm.cached_guild_avatar_id
|
||||
FROM users u
|
||||
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND gm.guild_id = ${guildId}
|
||||
WHERE u.user_id = ANY(${authorIds})
|
||||
`;
|
||||
for (const info of avatarInfo) {
|
||||
userAvatarCache[info.user_id.toString()] = {
|
||||
cachedAvatarId: info.cached_guild_avatar_id || info.cached_avatar_id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Extract all mentioned user IDs from message content
|
||||
const mentionedUserIds = new Set();
|
||||
const userMentionRegex = /<@!?(\d+)>/g;
|
||||
for (const msg of messages) {
|
||||
if (msg.content) {
|
||||
let match;
|
||||
while ((match = userMentionRegex.exec(msg.content)) !== null) {
|
||||
mentionedUserIds.add(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch mentioned users with their display names and colors
|
||||
let mentionedUsers = [];
|
||||
if (mentionedUserIds.size > 0) {
|
||||
const mentionedIds = Array.from(mentionedUserIds);
|
||||
mentionedUsers = await sql`
|
||||
SELECT
|
||||
u.user_id,
|
||||
u.username,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as display_name,
|
||||
(
|
||||
SELECT r.color
|
||||
FROM roles r
|
||||
WHERE r.role_id = ANY(gm.roles)
|
||||
AND r.color IS NOT NULL
|
||||
AND r.color != 0
|
||||
ORDER BY r.position DESC
|
||||
LIMIT 1
|
||||
) as color
|
||||
FROM users u
|
||||
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND gm.guild_id = ${guildId}
|
||||
WHERE u.user_id = ANY(${mentionedIds})
|
||||
`;
|
||||
}
|
||||
|
||||
// Build users map for mentions
|
||||
const usersMap = {};
|
||||
for (const user of mentionedUsers) {
|
||||
const colorHex = user.color ? `#${user.color.toString(16).padStart(6, '0')}` : null;
|
||||
usersMap[user.user_id.toString()] = {
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
color: colorHex,
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch attachments for all messages
|
||||
const attachments = await sql`
|
||||
SELECT
|
||||
attachment_id, message_id, filename, url, proxy_url,
|
||||
content_type, size, width, height, cached_image_id
|
||||
FROM attachments
|
||||
WHERE message_id = ANY(${messageIds})
|
||||
`;
|
||||
|
||||
// Fetch embeds for all messages
|
||||
const embeds = await sql`
|
||||
SELECT
|
||||
embed_id, message_id, embed_type, title, description, url,
|
||||
raw_data,
|
||||
color, timestamp as embed_timestamp,
|
||||
footer_text, footer_icon_url, cached_footer_icon_id,
|
||||
image_url, image_width, image_height, cached_image_id,
|
||||
thumbnail_url, thumbnail_width, thumbnail_height, cached_thumbnail_id,
|
||||
video_url, video_width, video_height,
|
||||
provider_name, provider_url,
|
||||
author_name, author_url, author_icon_url, cached_author_icon_id
|
||||
FROM embeds
|
||||
WHERE message_id = ANY(${messageIds})
|
||||
`;
|
||||
|
||||
// Fetch message components (buttons, selects, etc.) for all messages
|
||||
const messageComponents = await sql`
|
||||
SELECT
|
||||
component_id, message_id, component_type, custom_id, label, style, url, disabled, placeholder,
|
||||
min_values, max_values, raw_data
|
||||
FROM message_components
|
||||
WHERE message_id = ANY(${messageIds})
|
||||
ORDER BY component_id
|
||||
`;
|
||||
|
||||
// Fetch embed fields for all embeds
|
||||
const embedIds = embeds.map(e => e.embed_id);
|
||||
let embedFields = [];
|
||||
if (embedIds.length > 0) {
|
||||
embedFields = await sql`
|
||||
SELECT embed_id, name, value, inline, position
|
||||
FROM embed_fields
|
||||
WHERE embed_id = ANY(${embedIds})
|
||||
ORDER BY position ASC
|
||||
`;
|
||||
}
|
||||
|
||||
// Index embed fields by embed_id
|
||||
const fieldsByEmbed = {};
|
||||
for (const field of embedFields) {
|
||||
if (!fieldsByEmbed[field.embed_id]) {
|
||||
fieldsByEmbed[field.embed_id] = [];
|
||||
}
|
||||
fieldsByEmbed[field.embed_id].push({
|
||||
name: field.name,
|
||||
value: field.value,
|
||||
inline: field.inline || false,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch reactions for all messages (aggregate counts since each reaction is a row)
|
||||
const reactions = await sql`
|
||||
SELECT
|
||||
message_id, emoji_id, emoji_name, emoji_animated,
|
||||
COUNT(*) FILTER (WHERE is_removed = FALSE) as count
|
||||
FROM reactions
|
||||
WHERE message_id = ANY(${messageIds})
|
||||
AND is_removed = FALSE
|
||||
GROUP BY message_id, emoji_id, emoji_name, emoji_animated
|
||||
`;
|
||||
|
||||
// Fetch stickers for all messages
|
||||
const stickers = await sql`
|
||||
SELECT
|
||||
ms.message_id,
|
||||
s.sticker_id,
|
||||
s.name,
|
||||
s.format_type
|
||||
FROM message_stickers ms
|
||||
JOIN stickers s ON ms.sticker_id = s.sticker_id
|
||||
WHERE ms.message_id = ANY(${messageIds})
|
||||
`;
|
||||
|
||||
// Fetch polls for all messages
|
||||
const polls = await sql`
|
||||
SELECT
|
||||
p.message_id,
|
||||
p.question_text,
|
||||
p.question_emoji_id,
|
||||
p.question_emoji_name,
|
||||
p.question_emoji_animated,
|
||||
p.allow_multiselect,
|
||||
p.expiry,
|
||||
p.is_finalized,
|
||||
p.total_votes
|
||||
FROM polls p
|
||||
WHERE p.message_id = ANY(${messageIds})
|
||||
`;
|
||||
|
||||
// Fetch poll answers
|
||||
const pollAnswers = await sql`
|
||||
SELECT
|
||||
pa.message_id,
|
||||
pa.answer_id,
|
||||
pa.answer_text,
|
||||
pa.answer_emoji_id,
|
||||
pa.answer_emoji_name,
|
||||
pa.answer_emoji_animated,
|
||||
pa.vote_count
|
||||
FROM poll_answers pa
|
||||
WHERE pa.message_id = ANY(${messageIds})
|
||||
ORDER BY pa.answer_id
|
||||
`;
|
||||
|
||||
// Fetch poll votes with user info
|
||||
const pollMessageIds = polls.map(p => p.message_id);
|
||||
let pollVotes = [];
|
||||
if (pollMessageIds.length > 0) {
|
||||
pollVotes = await sql`
|
||||
SELECT
|
||||
pv.message_id,
|
||||
pv.answer_id,
|
||||
pv.user_id,
|
||||
u.username,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as display_name,
|
||||
COALESCE(gm.guild_avatar_url, u.avatar_url) as avatar_url
|
||||
FROM poll_votes pv
|
||||
LEFT JOIN users u ON pv.user_id = u.user_id
|
||||
LEFT JOIN guild_members gm ON pv.user_id = gm.user_id AND gm.guild_id = ${guildId}
|
||||
WHERE pv.message_id = ANY(${pollMessageIds})
|
||||
AND pv.is_removed = FALSE
|
||||
ORDER BY pv.voted_at ASC
|
||||
`;
|
||||
}
|
||||
|
||||
// Fetch referenced messages for replies
|
||||
const referencedIds = messages
|
||||
.filter(m => m.reference_message_id)
|
||||
.map(m => m.reference_message_id);
|
||||
|
||||
let referencedMessages = [];
|
||||
if (referencedIds.length > 0) {
|
||||
referencedMessages = await sql`
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.content,
|
||||
u.user_id as author_id,
|
||||
u.username as author_username,
|
||||
u.avatar_url as author_avatar,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as author_display_name,
|
||||
COALESCE(gm.guild_avatar_url, u.avatar_url) as author_guild_avatar
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON m.author_id = u.user_id
|
||||
LEFT JOIN channels c ON m.channel_id = c.channel_id
|
||||
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND c.guild_id = gm.guild_id
|
||||
WHERE m.message_id = ANY(${referencedIds})
|
||||
`;
|
||||
}
|
||||
|
||||
// Index related data by message_id for quick lookup
|
||||
const attachmentsByMessage = {};
|
||||
const embedsByMessage = {};
|
||||
const reactionsByMessage = {};
|
||||
const stickersByMessage = {};
|
||||
const componentsByMessage = {};
|
||||
const pollsByMessage = {};
|
||||
const referencedById = {};
|
||||
|
||||
for (const att of attachments) {
|
||||
if (!attachmentsByMessage[att.message_id]) {
|
||||
attachmentsByMessage[att.message_id] = [];
|
||||
}
|
||||
attachmentsByMessage[att.message_id].push({
|
||||
id: att.attachment_id.toString(),
|
||||
filename: att.filename,
|
||||
url: getCachedOrProxyUrl(att.cached_image_id, att.url || att.proxy_url, baseUrl),
|
||||
contentType: att.content_type,
|
||||
size: att.size,
|
||||
width: att.width,
|
||||
height: att.height,
|
||||
});
|
||||
}
|
||||
|
||||
for (const embed of embeds) {
|
||||
if (!embedsByMessage[embed.message_id]) {
|
||||
embedsByMessage[embed.message_id] = [];
|
||||
}
|
||||
embedsByMessage[embed.message_id].push({
|
||||
type: embed.embed_type || 'rich',
|
||||
title: embed.title,
|
||||
description: embed.description,
|
||||
url: embed.url,
|
||||
color: embed.color,
|
||||
timestamp: embed.embed_timestamp,
|
||||
footer: embed.footer_text ? {
|
||||
text: embed.footer_text,
|
||||
iconUrl: getCachedOrProxyUrl(embed.cached_footer_icon_id, embed.footer_icon_url, baseUrl),
|
||||
} : null,
|
||||
image: embed.image_url ? {
|
||||
url: getCachedOrProxyUrl(embed.cached_image_id, embed.image_url, baseUrl),
|
||||
width: embed.image_width,
|
||||
height: embed.image_height,
|
||||
} : null,
|
||||
thumbnail: embed.thumbnail_url ? {
|
||||
url: getCachedOrProxyUrl(embed.cached_thumbnail_id, embed.thumbnail_url, baseUrl),
|
||||
width: embed.thumbnail_width,
|
||||
height: embed.thumbnail_height,
|
||||
} : null,
|
||||
video: embed.video_url ? {
|
||||
url: embed.video_url, // Video URLs usually need to be direct for embedding
|
||||
width: embed.video_width,
|
||||
height: embed.video_height,
|
||||
} : null,
|
||||
provider: embed.provider_name ? {
|
||||
name: embed.provider_name,
|
||||
url: embed.provider_url,
|
||||
} : null,
|
||||
author: embed.author_name ? {
|
||||
name: embed.author_name,
|
||||
url: embed.author_url,
|
||||
iconUrl: getCachedOrProxyUrl(embed.cached_author_icon_id, embed.author_icon_url, baseUrl),
|
||||
} : null,
|
||||
fields: fieldsByEmbed[embed.embed_id] || [],
|
||||
rawData: embed.raw_data || null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const reaction of reactions) {
|
||||
if (!reactionsByMessage[reaction.message_id]) {
|
||||
reactionsByMessage[reaction.message_id] = [];
|
||||
}
|
||||
reactionsByMessage[reaction.message_id].push({
|
||||
emoji: reaction.emoji_id
|
||||
? { id: reaction.emoji_id.toString(), name: reaction.emoji_name, animated: reaction.emoji_animated }
|
||||
: { name: reaction.emoji_name },
|
||||
count: parseInt(reaction.count, 10),
|
||||
});
|
||||
}
|
||||
|
||||
// Sticker format types: 1=PNG, 2=APNG, 3=Lottie, 4=GIF
|
||||
const stickerExtensions = { 1: 'png', 2: 'png', 3: 'json', 4: 'gif' };
|
||||
for (const sticker of stickers) {
|
||||
if (!stickersByMessage[sticker.message_id]) {
|
||||
stickersByMessage[sticker.message_id] = [];
|
||||
}
|
||||
const ext = stickerExtensions[sticker.format_type] || 'png';
|
||||
stickersByMessage[sticker.message_id].push({
|
||||
id: sticker.sticker_id.toString(),
|
||||
name: sticker.name,
|
||||
formatType: sticker.format_type,
|
||||
url: `https://media.discordapp.net/stickers/${sticker.sticker_id}.${ext}?size=160`,
|
||||
});
|
||||
}
|
||||
|
||||
// Index message components
|
||||
for (const comp of messageComponents) {
|
||||
if (!componentsByMessage[comp.message_id]) componentsByMessage[comp.message_id] = [];
|
||||
componentsByMessage[comp.message_id].push({
|
||||
id: comp.component_id.toString(),
|
||||
type: comp.component_type,
|
||||
customId: comp.custom_id,
|
||||
label: comp.label,
|
||||
style: comp.style,
|
||||
url: comp.url,
|
||||
disabled: !!comp.disabled,
|
||||
placeholder: comp.placeholder,
|
||||
minValues: comp.min_values,
|
||||
maxValues: comp.max_values,
|
||||
rawData: comp.raw_data || null,
|
||||
});
|
||||
}
|
||||
|
||||
// Index poll votes by message_id and answer_id
|
||||
const pollVotesByAnswer = {};
|
||||
for (const vote of pollVotes) {
|
||||
const key = `${vote.message_id}-${vote.answer_id}`;
|
||||
if (!pollVotesByAnswer[key]) {
|
||||
pollVotesByAnswer[key] = [];
|
||||
}
|
||||
// Build avatar URL
|
||||
let avatarUrl = null;
|
||||
if (vote.avatar_url) {
|
||||
if (vote.avatar_url.startsWith('http')) {
|
||||
avatarUrl = vote.avatar_url;
|
||||
} else {
|
||||
avatarUrl = `https://cdn.discordapp.com/avatars/${vote.user_id}/${vote.avatar_url}.png?size=32`;
|
||||
}
|
||||
}
|
||||
pollVotesByAnswer[key].push({
|
||||
id: vote.user_id.toString(),
|
||||
username: vote.username,
|
||||
displayName: vote.display_name || vote.username,
|
||||
avatar: avatarUrl,
|
||||
});
|
||||
}
|
||||
|
||||
// Index poll answers by message_id
|
||||
const pollAnswersByMessage = {};
|
||||
for (const answer of pollAnswers) {
|
||||
if (!pollAnswersByMessage[answer.message_id]) {
|
||||
pollAnswersByMessage[answer.message_id] = [];
|
||||
}
|
||||
const voteKey = `${answer.message_id}-${answer.answer_id}`;
|
||||
pollAnswersByMessage[answer.message_id].push({
|
||||
id: answer.answer_id,
|
||||
text: answer.answer_text,
|
||||
emoji: answer.answer_emoji_id
|
||||
? { id: answer.answer_emoji_id.toString(), name: answer.answer_emoji_name, animated: answer.answer_emoji_animated }
|
||||
: answer.answer_emoji_name ? { name: answer.answer_emoji_name } : null,
|
||||
voteCount: answer.vote_count || 0,
|
||||
voters: pollVotesByAnswer[voteKey] || [],
|
||||
});
|
||||
}
|
||||
|
||||
// Index polls by message_id
|
||||
for (const poll of polls) {
|
||||
const answers = pollAnswersByMessage[poll.message_id] || [];
|
||||
pollsByMessage[poll.message_id] = {
|
||||
question: {
|
||||
text: poll.question_text,
|
||||
emoji: poll.question_emoji_id
|
||||
? { id: poll.question_emoji_id.toString(), name: poll.question_emoji_name, animated: poll.question_emoji_animated }
|
||||
: poll.question_emoji_name ? { name: poll.question_emoji_name } : null,
|
||||
},
|
||||
answers,
|
||||
allowMultiselect: poll.allow_multiselect || false,
|
||||
expiry: poll.expiry?.toISOString() || null,
|
||||
isFinalized: poll.is_finalized || false,
|
||||
totalVotes: poll.total_votes || 0,
|
||||
};
|
||||
}
|
||||
|
||||
for (const ref of referencedMessages) {
|
||||
// Handle avatar URL for referenced message author
|
||||
const refAvatarSource = ref.author_guild_avatar || ref.author_avatar;
|
||||
let refAvatarUrl;
|
||||
if (refAvatarSource) {
|
||||
if (refAvatarSource.startsWith('http')) {
|
||||
refAvatarUrl = refAvatarSource;
|
||||
} else {
|
||||
refAvatarUrl = `https://cdn.discordapp.com/avatars/${ref.author_id}/${refAvatarSource}.png?size=32`;
|
||||
}
|
||||
}
|
||||
|
||||
referencedById[ref.message_id] = {
|
||||
id: ref.message_id.toString(),
|
||||
content: ref.content,
|
||||
author: {
|
||||
id: ref.author_id?.toString(),
|
||||
username: ref.author_username,
|
||||
displayName: ref.author_display_name,
|
||||
avatar: refAvatarUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Build the formatted response
|
||||
const formattedMessages = messages.map(msg => {
|
||||
// Check for cached avatar first
|
||||
const authorId = msg.author_id?.toString();
|
||||
const cachedInfo = userAvatarCache[authorId];
|
||||
|
||||
let avatarUrl;
|
||||
if (cachedInfo?.cachedAvatarId) {
|
||||
// Use cached avatar from database
|
||||
const sig = signImageId(cachedInfo.cachedAvatarId);
|
||||
avatarUrl = `${baseUrl}/api/discord/cached-image?id=${cachedInfo.cachedAvatarId}&sig=${sig}`;
|
||||
} else {
|
||||
// Handle avatar URL - prefer guild avatar, then user avatar
|
||||
const avatarSource = msg.author_guild_avatar || msg.author_avatar;
|
||||
if (avatarSource) {
|
||||
// If it's already a full URL, use it directly
|
||||
if (avatarSource.startsWith('http')) {
|
||||
avatarUrl = avatarSource;
|
||||
} else {
|
||||
// Otherwise, construct the CDN URL from the hash
|
||||
// Guild avatars use a different path
|
||||
if (msg.author_guild_avatar && guildId) {
|
||||
avatarUrl = `https://cdn.discordapp.com/guilds/${guildId}/users/${msg.author_id}/avatars/${msg.author_guild_avatar}.png`;
|
||||
} else {
|
||||
avatarUrl = `https://cdn.discordapp.com/avatars/${msg.author_id}/${avatarSource}.png`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
avatarUrl = `https://cdn.discordapp.com/embed/avatars/${(parseInt(msg.author_discriminator || '0', 10) || parseInt(msg.author_id, 10)) % 5}.png`;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert color from decimal to hex if present
|
||||
const colorHex = msg.author_color ? `#${msg.author_color.toString(16).padStart(6, '0')}` : null;
|
||||
|
||||
// Determine if this is a server-forwarded webhook message
|
||||
// Server-forwarded messages have a webhook_id AND reference_guild_id differs from current guild
|
||||
const isWebhook = !!msg.webhook_id;
|
||||
const isServerForwarded = isWebhook && msg.reference_guild_id &&
|
||||
msg.reference_guild_id.toString() !== msg.message_guild_id?.toString();
|
||||
|
||||
return {
|
||||
id: msg.message_id.toString(),
|
||||
type: msg.message_type,
|
||||
content: msg.content,
|
||||
timestamp: msg.created_at?.toISOString(),
|
||||
editedTimestamp: msg.edited_at?.toISOString() || null,
|
||||
author: {
|
||||
id: msg.author_id?.toString(),
|
||||
username: msg.author_username || 'Unknown User',
|
||||
displayName: msg.author_display_name || msg.author_username || 'Unknown User',
|
||||
discriminator: msg.author_discriminator || '0000',
|
||||
avatar: avatarUrl,
|
||||
bot: msg.author_bot || false,
|
||||
isWebhook: isWebhook,
|
||||
isServerForwarded: isServerForwarded,
|
||||
color: colorHex,
|
||||
},
|
||||
attachments: attachmentsByMessage[msg.message_id] || [],
|
||||
embeds: embedsByMessage[msg.message_id] || [],
|
||||
components: componentsByMessage[msg.message_id] || [],
|
||||
rawData: msg.raw_data || null,
|
||||
stickers: stickersByMessage[msg.message_id] || [],
|
||||
reactions: reactionsByMessage[msg.message_id] || [],
|
||||
poll: pollsByMessage[msg.message_id] || null,
|
||||
referencedMessage: msg.reference_message_id
|
||||
? referencedById[msg.reference_message_id] || null
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
return createResponse({ messages: formattedMessages, users: usersMap });
|
||||
} catch (error) {
|
||||
console.error('Error fetching messages:', error);
|
||||
return createResponse({ error: 'Failed to fetch messages' }, 500);
|
||||
}
|
||||
}
|
||||
138
src/pages/api/discord/reaction-users.js
Normal file
138
src/pages/api/discord/reaction-users.js
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* API endpoint to fetch users who reacted with a specific emoji on a message
|
||||
*/
|
||||
import sql from '../../../utils/db.js';
|
||||
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
} from '../../../utils/rateLimit.js';
|
||||
import { signImageId } from './cached-image.js';
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit check
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 20,
|
||||
windowMs: 1000,
|
||||
burstLimit: 100,
|
||||
burstWindowMs: 10_000,
|
||||
});
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
return new Response(JSON.stringify({
|
||||
error: rateCheck.isFlooding ? 'Too many requests - please slow down' : 'Rate limit exceeded'
|
||||
}), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||
});
|
||||
}
|
||||
|
||||
recordRequest(request, 1000);
|
||||
|
||||
// Check authentication
|
||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
// Helper to create responses with optional Set-Cookie header
|
||||
const createResponse = (data, status = 200) => new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(setCookieHeader && { 'Set-Cookie': setCookieHeader }),
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to validate Discord snowflake IDs (17-20 digit strings)
|
||||
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const messageId = url.searchParams.get('messageId');
|
||||
const emojiName = url.searchParams.get('emojiName');
|
||||
const emojiId = url.searchParams.get('emojiId'); // null for unicode emoji
|
||||
|
||||
if (!messageId || !emojiName) {
|
||||
return createResponse({ error: 'messageId and emojiName are required' }, 400);
|
||||
}
|
||||
|
||||
// Validate snowflake formats
|
||||
if (!isValidSnowflake(messageId) || !isValidSnowflake(emojiId)) {
|
||||
return createResponse({ error: 'Invalid ID format' }, 400);
|
||||
}
|
||||
|
||||
// Get the guild_id from the message's channel for proper display names
|
||||
const messageInfo = await sql`
|
||||
SELECT c.guild_id
|
||||
FROM messages m
|
||||
JOIN channels c ON m.channel_id = c.channel_id
|
||||
WHERE m.message_id = ${messageId}
|
||||
`;
|
||||
const guildId = messageInfo[0]?.guild_id;
|
||||
|
||||
// Fetch users who reacted with this emoji
|
||||
let users;
|
||||
if (emojiId) {
|
||||
users = await sql`
|
||||
SELECT
|
||||
u.user_id,
|
||||
u.username,
|
||||
u.avatar_url,
|
||||
u.cached_avatar_id,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as display_name,
|
||||
COALESCE(gm.guild_avatar_url, u.avatar_url) as guild_avatar,
|
||||
gm.cached_guild_avatar_id
|
||||
FROM reactions r
|
||||
JOIN users u ON r.user_id = u.user_id
|
||||
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND gm.guild_id = ${guildId}
|
||||
WHERE r.message_id = ${messageId}
|
||||
AND r.emoji_name = ${emojiName}
|
||||
AND r.emoji_id = ${emojiId}
|
||||
AND r.is_removed = FALSE
|
||||
ORDER BY r.added_at ASC
|
||||
`;
|
||||
} else {
|
||||
users = await sql`
|
||||
SELECT
|
||||
u.user_id,
|
||||
u.username,
|
||||
u.avatar_url,
|
||||
u.cached_avatar_id,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as display_name,
|
||||
COALESCE(gm.guild_avatar_url, u.avatar_url) as guild_avatar,
|
||||
gm.cached_guild_avatar_id
|
||||
FROM reactions r
|
||||
JOIN users u ON r.user_id = u.user_id
|
||||
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND gm.guild_id = ${guildId}
|
||||
WHERE r.message_id = ${messageId}
|
||||
AND r.emoji_name = ${emojiName}
|
||||
AND r.emoji_id IS NULL
|
||||
AND r.is_removed = FALSE
|
||||
ORDER BY r.added_at ASC
|
||||
`;
|
||||
}
|
||||
|
||||
const formattedUsers = users.map(u => {
|
||||
// Prefer cached avatar
|
||||
const cachedAvatarId = u.cached_guild_avatar_id || u.cached_avatar_id;
|
||||
let avatar;
|
||||
if (cachedAvatarId) {
|
||||
const sig = signImageId(cachedAvatarId);
|
||||
avatar = `/api/discord/cached-image?id=${cachedAvatarId}&sig=${sig}`;
|
||||
} else {
|
||||
avatar = u.guild_avatar || u.avatar_url;
|
||||
}
|
||||
|
||||
return {
|
||||
id: u.user_id.toString(),
|
||||
username: u.username,
|
||||
displayName: u.display_name,
|
||||
avatar,
|
||||
};
|
||||
});
|
||||
|
||||
return createResponse(formattedUsers);
|
||||
} catch (error) {
|
||||
console.error('Error fetching reaction users:', error);
|
||||
return createResponse({ error: 'Failed to fetch reaction users' }, 500);
|
||||
}
|
||||
}
|
||||
318
src/pages/api/discord/search.js
Normal file
318
src/pages/api/discord/search.js
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* API endpoint to search Discord messages
|
||||
* Searches message content and embed content
|
||||
*/
|
||||
import sql from '../../../utils/db.js';
|
||||
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
getCookieId,
|
||||
} from '../../../utils/rateLimit.js';
|
||||
|
||||
// Escape LIKE/ILIKE metacharacters to prevent pattern injection
|
||||
function escapeLikePattern(str) {
|
||||
return str.replace(/[%_\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit check for authenticated endpoints
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 10,
|
||||
windowMs: 1000,
|
||||
burstLimit: 50,
|
||||
burstWindowMs: 10_000,
|
||||
});
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
return new Response(JSON.stringify({
|
||||
error: rateCheck.isFlooding ? 'Too many requests - please slow down' : 'Rate limit exceeded'
|
||||
}), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||
});
|
||||
}
|
||||
|
||||
recordRequest(request, 1000);
|
||||
|
||||
// Check authentication
|
||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
||||
if (authError) return authError;
|
||||
if (authError) return authError;
|
||||
|
||||
// Helper to create responses with optional Set-Cookie header
|
||||
const createResponse = (data, status = 200) => new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(setCookieHeader && { 'Set-Cookie': setCookieHeader }),
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to validate Discord snowflake IDs (17-20 digit strings)
|
||||
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const channelId = url.searchParams.get('channelId');
|
||||
const query = url.searchParams.get('q');
|
||||
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 100);
|
||||
|
||||
if (!channelId) {
|
||||
return createResponse({ error: 'channelId is required' }, 400);
|
||||
}
|
||||
|
||||
// Validate snowflake format
|
||||
if (!isValidSnowflake(channelId)) {
|
||||
return createResponse({ error: 'Invalid channelId format' }, 400);
|
||||
}
|
||||
|
||||
if (!query || query.trim().length < 2) {
|
||||
return createResponse({ error: 'Search query must be at least 2 characters' }, 400);
|
||||
}
|
||||
|
||||
// Escape LIKE metacharacters to prevent pattern injection
|
||||
const escapedQuery = escapeLikePattern(query);
|
||||
const searchPattern = `%${escapedQuery}%`;
|
||||
|
||||
// Search messages by content, author username, or embed content
|
||||
const messages = await sql`
|
||||
SELECT DISTINCT
|
||||
m.message_id,
|
||||
m.content,
|
||||
m.created_at,
|
||||
m.edited_at,
|
||||
m.reference_message_id,
|
||||
m.channel_id,
|
||||
u.user_id as author_id,
|
||||
u.username as author_username,
|
||||
u.discriminator as author_discriminator,
|
||||
u.avatar_url as author_avatar,
|
||||
u.is_bot as author_bot,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as author_display_name,
|
||||
COALESCE(gm.guild_avatar_url, u.avatar_url) as author_guild_avatar,
|
||||
(
|
||||
SELECT r.color
|
||||
FROM roles r
|
||||
WHERE r.role_id = ANY(gm.roles)
|
||||
AND r.color IS NOT NULL
|
||||
AND r.color != 0
|
||||
ORDER BY r.position DESC
|
||||
LIMIT 1
|
||||
) as author_color
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON m.author_id = u.user_id
|
||||
LEFT JOIN channels c ON m.channel_id = c.channel_id
|
||||
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND c.guild_id = gm.guild_id
|
||||
LEFT JOIN embeds e ON m.message_id = e.message_id
|
||||
WHERE m.channel_id = ${channelId}
|
||||
AND m.is_deleted = FALSE
|
||||
AND (
|
||||
m.content ILIKE ${searchPattern}
|
||||
OR u.username ILIKE ${searchPattern}
|
||||
OR u.global_name ILIKE ${searchPattern}
|
||||
OR gm.nickname ILIKE ${searchPattern}
|
||||
OR e.title ILIKE ${searchPattern}
|
||||
OR e.description ILIKE ${searchPattern}
|
||||
OR e.author_name ILIKE ${searchPattern}
|
||||
)
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
if (messages.length === 0) {
|
||||
return createResponse({ messages: [], totalCount: 0 });
|
||||
}
|
||||
|
||||
// Get message IDs for fetching related data
|
||||
const messageIds = messages.map(m => m.message_id);
|
||||
|
||||
// Fetch attachments
|
||||
const attachments = await sql`
|
||||
SELECT
|
||||
message_id,
|
||||
attachment_id,
|
||||
filename,
|
||||
url,
|
||||
proxy_url,
|
||||
content_type,
|
||||
size,
|
||||
width,
|
||||
height
|
||||
FROM attachments
|
||||
WHERE message_id = ANY(${messageIds})
|
||||
ORDER BY attachment_id
|
||||
`;
|
||||
|
||||
// Fetch embeds
|
||||
const embeds = await sql`
|
||||
SELECT
|
||||
embed_id, message_id, embed_type, title, description, url,
|
||||
color, timestamp as embed_timestamp,
|
||||
footer_text, footer_icon_url,
|
||||
image_url, image_width, image_height,
|
||||
thumbnail_url, thumbnail_width, thumbnail_height,
|
||||
video_url, video_width, video_height,
|
||||
provider_name, provider_url,
|
||||
author_name, author_url, author_icon_url
|
||||
FROM embeds
|
||||
WHERE message_id = ANY(${messageIds})
|
||||
`;
|
||||
|
||||
// Fetch reactions
|
||||
const reactions = await sql`
|
||||
SELECT
|
||||
message_id,
|
||||
emoji_id,
|
||||
emoji_name,
|
||||
emoji_animated,
|
||||
COUNT(*) as count
|
||||
FROM reactions
|
||||
WHERE message_id = ANY(${messageIds})
|
||||
AND is_removed = FALSE
|
||||
GROUP BY message_id, emoji_id, emoji_name, emoji_animated
|
||||
`;
|
||||
|
||||
// Fetch referenced messages (for replies)
|
||||
const referencedIds = messages
|
||||
.filter(m => m.reference_message_id)
|
||||
.map(m => m.reference_message_id);
|
||||
|
||||
let referencedMessages = [];
|
||||
if (referencedIds.length > 0) {
|
||||
referencedMessages = await sql`
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.content,
|
||||
u.user_id as author_id,
|
||||
u.username as author_username,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as author_display_name
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON m.author_id = u.user_id
|
||||
LEFT JOIN channels c ON m.channel_id = c.channel_id
|
||||
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND c.guild_id = gm.guild_id
|
||||
WHERE m.message_id = ANY(${referencedIds})
|
||||
`;
|
||||
}
|
||||
|
||||
// Build referenced message map
|
||||
const referencedMap = {};
|
||||
referencedMessages.forEach(ref => {
|
||||
referencedMap[ref.message_id] = {
|
||||
id: ref.message_id,
|
||||
content: ref.content,
|
||||
author: {
|
||||
id: ref.author_id,
|
||||
username: ref.author_username,
|
||||
displayName: ref.author_display_name,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Group attachments, embeds, and reactions by message
|
||||
const attachmentsByMessage = {};
|
||||
attachments.forEach(att => {
|
||||
if (!attachmentsByMessage[att.message_id]) {
|
||||
attachmentsByMessage[att.message_id] = [];
|
||||
}
|
||||
attachmentsByMessage[att.message_id].push({
|
||||
id: att.attachment_id,
|
||||
filename: att.filename,
|
||||
url: att.url,
|
||||
proxyUrl: att.proxy_url,
|
||||
contentType: att.content_type,
|
||||
size: att.size,
|
||||
width: att.width,
|
||||
height: att.height,
|
||||
});
|
||||
});
|
||||
|
||||
const embedsByMessage = {};
|
||||
embeds.forEach(emb => {
|
||||
if (!embedsByMessage[emb.message_id]) {
|
||||
embedsByMessage[emb.message_id] = [];
|
||||
}
|
||||
embedsByMessage[emb.message_id].push({
|
||||
type: emb.embed_type,
|
||||
title: emb.title,
|
||||
description: emb.description,
|
||||
url: emb.url,
|
||||
color: emb.color,
|
||||
timestamp: emb.embed_timestamp,
|
||||
footer: emb.footer_text ? {
|
||||
text: emb.footer_text,
|
||||
iconUrl: emb.footer_icon_url,
|
||||
} : null,
|
||||
image: emb.image_url ? {
|
||||
url: emb.image_url,
|
||||
width: emb.image_width,
|
||||
height: emb.image_height,
|
||||
} : null,
|
||||
thumbnail: emb.thumbnail_url ? {
|
||||
url: emb.thumbnail_url,
|
||||
width: emb.thumbnail_width,
|
||||
height: emb.thumbnail_height,
|
||||
} : null,
|
||||
video: emb.video_url ? {
|
||||
url: emb.video_url,
|
||||
width: emb.video_width,
|
||||
height: emb.video_height,
|
||||
} : null,
|
||||
provider: emb.provider_name ? {
|
||||
name: emb.provider_name,
|
||||
url: emb.provider_url,
|
||||
} : null,
|
||||
author: emb.author_name ? {
|
||||
name: emb.author_name,
|
||||
url: emb.author_url,
|
||||
iconUrl: emb.author_icon_url,
|
||||
} : null,
|
||||
fields: [],
|
||||
});
|
||||
});
|
||||
|
||||
const reactionsByMessage = {};
|
||||
reactions.forEach(r => {
|
||||
if (!reactionsByMessage[r.message_id]) {
|
||||
reactionsByMessage[r.message_id] = [];
|
||||
}
|
||||
reactionsByMessage[r.message_id].push({
|
||||
emoji: {
|
||||
id: r.emoji_id,
|
||||
name: r.emoji_name,
|
||||
animated: r.emoji_animated,
|
||||
},
|
||||
count: r.count,
|
||||
});
|
||||
});
|
||||
|
||||
// Format response
|
||||
const formattedMessages = messages.map(msg => ({
|
||||
id: msg.message_id,
|
||||
content: msg.content,
|
||||
timestamp: msg.created_at,
|
||||
editedAt: msg.edited_at,
|
||||
author: {
|
||||
id: msg.author_id,
|
||||
username: msg.author_username,
|
||||
discriminator: msg.author_discriminator,
|
||||
avatar: msg.author_guild_avatar || msg.author_avatar,
|
||||
displayName: msg.author_display_name,
|
||||
bot: msg.author_bot,
|
||||
color: msg.author_color ? `#${msg.author_color.toString(16).padStart(6, '0')}` : null,
|
||||
},
|
||||
attachments: attachmentsByMessage[msg.message_id] || [],
|
||||
embeds: embedsByMessage[msg.message_id] || [],
|
||||
reactions: reactionsByMessage[msg.message_id] || [],
|
||||
referencedMessage: msg.reference_message_id ? referencedMap[msg.reference_message_id] : null,
|
||||
}));
|
||||
|
||||
return createResponse({
|
||||
messages: formattedMessages,
|
||||
totalCount: formattedMessages.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
return createResponse({ error: 'Failed to search messages' }, 500);
|
||||
}
|
||||
}
|
||||
269
src/pages/api/image-proxy.js
Normal file
269
src/pages/api/image-proxy.js
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Server-side image proxy API endpoint
|
||||
* Proxies images from untrusted domains to prevent user IP exposure
|
||||
* Uses HMAC signatures to prevent abuse
|
||||
*/
|
||||
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
getCookieId,
|
||||
generateNonce,
|
||||
createNonceCookie,
|
||||
} from '../../utils/rateLimit.js';
|
||||
|
||||
// Secret key for signing URLs - MUST be set in production
|
||||
const SIGNING_SECRET = import.meta.env.IMAGE_PROXY_SECRET;
|
||||
if (!SIGNING_SECRET) {
|
||||
console.error('CRITICAL: IMAGE_PROXY_SECRET environment variable is not set!');
|
||||
}
|
||||
|
||||
// Private IP ranges to block (SSRF protection)
|
||||
const PRIVATE_IP_PATTERNS = [
|
||||
/^127\./, // localhost
|
||||
/^10\./, // Class A private
|
||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // Class B private
|
||||
/^192\.168\./, // Class C private
|
||||
/^169\.254\./, // Link-local
|
||||
/^0\./, // Current network
|
||||
/^224\./, // Multicast
|
||||
/^255\./, // Broadcast
|
||||
/^localhost$/i,
|
||||
/^\[?::1\]?$/, // IPv6 localhost
|
||||
/^\[?fe80:/i, // IPv6 link-local
|
||||
/^\[?fc00:/i, // IPv6 private
|
||||
/^\[?fd00:/i, // IPv6 private
|
||||
];
|
||||
|
||||
function isPrivateUrl(urlString) {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
const hostname = url.hostname;
|
||||
return PRIVATE_IP_PATTERNS.some(pattern => pattern.test(hostname));
|
||||
} catch {
|
||||
return true; // Block invalid URLs
|
||||
}
|
||||
}
|
||||
|
||||
// Max image size to proxy (25MB - needed for animated GIFs)
|
||||
const MAX_IMAGE_SIZE = 25 * 1024 * 1024;
|
||||
|
||||
// Allowed content types
|
||||
const ALLOWED_CONTENT_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
'image/bmp',
|
||||
'image/x-icon',
|
||||
'image/vnd.microsoft.icon',
|
||||
'image/avif',
|
||||
'image/apng',
|
||||
];
|
||||
|
||||
// Image extensions for fallback content-type detection
|
||||
const IMAGE_EXTENSIONS = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.bmp': 'image/bmp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.avif': 'image/avif',
|
||||
'.apng': 'image/apng',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get content type from URL extension
|
||||
*/
|
||||
function getContentTypeFromUrl(url) {
|
||||
try {
|
||||
const pathname = new URL(url).pathname.toLowerCase();
|
||||
for (const [ext, type] of Object.entries(IMAGE_EXTENSIONS)) {
|
||||
if (pathname.endsWith(ext)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HMAC signature for a URL
|
||||
*/
|
||||
export async function signImageUrl(imageUrl) {
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(SIGNING_SECRET),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(imageUrl));
|
||||
const signatureHex = Array.from(new Uint8Array(signature))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
return signatureHex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify HMAC signature for a URL
|
||||
*/
|
||||
async function verifySignature(imageUrl, signature) {
|
||||
const expectedSignature = await signImageUrl(imageUrl);
|
||||
return signature === expectedSignature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a signed proxy URL for an image
|
||||
*/
|
||||
export async function createSignedProxyUrl(imageUrl) {
|
||||
const signature = await signImageUrl(imageUrl);
|
||||
return `/api/image-proxy?url=${encodeURIComponent(imageUrl)}&sig=${signature}`;
|
||||
}
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit check
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 20,
|
||||
windowMs: 1000,
|
||||
burstLimit: 100,
|
||||
burstWindowMs: 10_000,
|
||||
});
|
||||
|
||||
let cookieId = getCookieId(request);
|
||||
const hadCookie = !!cookieId;
|
||||
if (!cookieId) {
|
||||
cookieId = generateNonce();
|
||||
}
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
const response = new Response('Rate limit exceeded', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '1' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
recordRequest(request, 1000);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const imageUrl = url.searchParams.get('url');
|
||||
const signature = url.searchParams.get('sig');
|
||||
|
||||
if (!imageUrl) {
|
||||
return new Response('Missing url parameter', { status: 400 });
|
||||
}
|
||||
|
||||
if (!signature) {
|
||||
return new Response('Missing signature', { status: 403 });
|
||||
}
|
||||
|
||||
// Require signing secret to be configured
|
||||
if (!SIGNING_SECRET) {
|
||||
return new Response('Server misconfigured', { status: 500 });
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
const isValid = await verifySignature(imageUrl, signature);
|
||||
if (!isValid) {
|
||||
return new Response('Invalid signature', { status: 403 });
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(imageUrl);
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||
throw new Error('Invalid protocol');
|
||||
}
|
||||
} catch {
|
||||
return new Response('Invalid URL', { status: 400 });
|
||||
}
|
||||
|
||||
// SSRF protection: block private/internal IPs
|
||||
if (isPrivateUrl(imageUrl)) {
|
||||
return new Response('URL not allowed', { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(imageUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; ImageProxy/1.0)',
|
||||
'Accept': 'image/*',
|
||||
},
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
return new Response('Failed to fetch image', { status: 502 });
|
||||
}
|
||||
|
||||
let contentType = response.headers.get('content-type') || '';
|
||||
const contentLength = parseInt(response.headers.get('content-length') || '0', 10);
|
||||
|
||||
// Validate content type - must be an image
|
||||
let isAllowedType = ALLOWED_CONTENT_TYPES.some(type => contentType.startsWith(type));
|
||||
|
||||
// If server returned generic binary type, try to infer from URL extension
|
||||
if (!isAllowedType && (contentType.includes('octet-stream') || contentType === '')) {
|
||||
const inferredType = getContentTypeFromUrl(imageUrl);
|
||||
if (inferredType) {
|
||||
contentType = inferredType;
|
||||
isAllowedType = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAllowedType) {
|
||||
console.error(`[image-proxy] Invalid content type: ${contentType} for URL: ${imageUrl}`);
|
||||
return new Response(`Invalid content type: ${contentType}`, { status: 400 });
|
||||
}
|
||||
|
||||
// Check size limit
|
||||
if (contentLength > MAX_IMAGE_SIZE) {
|
||||
return new Response('Image too large', { status: 413 });
|
||||
}
|
||||
|
||||
// Stream the image through
|
||||
const imageData = await response.arrayBuffer();
|
||||
|
||||
// Double-check size after download
|
||||
if (imageData.byteLength > MAX_IMAGE_SIZE) {
|
||||
return new Response('Image too large', { status: 413 });
|
||||
}
|
||||
|
||||
const proxyResponse = new Response(imageData, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': imageData.byteLength.toString(),
|
||||
'Cache-Control': 'public, max-age=86400', // Cache for 1 day
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
|
||||
if (!hadCookie) {
|
||||
proxyResponse.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
|
||||
return proxyResponse;
|
||||
|
||||
} catch (err) {
|
||||
console.error('[image-proxy] Error fetching image:', err.message);
|
||||
return new Response('Failed to fetch image', { status: 500 });
|
||||
}
|
||||
}
|
||||
279
src/pages/api/link-preview.js
Normal file
279
src/pages/api/link-preview.js
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Server-side link preview API endpoint (Node.js / Astro)
|
||||
* Uses linkedom for reliable HTML parsing and automatic entity decoding
|
||||
* Returns signed proxy URLs for images from untrusted domains
|
||||
*/
|
||||
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
getCookieId,
|
||||
generateNonce,
|
||||
createNonceCookie,
|
||||
} from '../../utils/rateLimit.js';
|
||||
import { signImageUrl } from './image-proxy.js';
|
||||
import { parseHTML } from 'linkedom';
|
||||
|
||||
// Trusted domains that can be loaded client-side
|
||||
const TRUSTED_DOMAINS = new Set([
|
||||
'youtube.com', 'www.youtube.com', 'youtu.be', 'img.youtube.com', 'i.ytimg.com',
|
||||
'instagram.com', 'www.instagram.com',
|
||||
'twitter.com', 'x.com', 'www.twitter.com', 'pbs.twimg.com', 'abs.twimg.com',
|
||||
'twitch.tv', 'www.twitch.tv', 'clips.twitch.tv',
|
||||
'spotify.com', 'open.spotify.com',
|
||||
'soundcloud.com', 'www.soundcloud.com',
|
||||
'vimeo.com', 'www.vimeo.com',
|
||||
'imgur.com', 'i.imgur.com',
|
||||
'giphy.com', 'media.giphy.com',
|
||||
'tenor.com', 'media.tenor.com',
|
||||
'gfycat.com',
|
||||
'reddit.com', 'www.reddit.com', 'v.redd.it', 'i.redd.it', 'preview.redd.it',
|
||||
'github.com', 'gist.github.com', 'raw.githubusercontent.com', 'avatars.githubusercontent.com', 'user-images.githubusercontent.com',
|
||||
'codepen.io', 'codesandbox.io',
|
||||
'streamable.com', 'medal.tv',
|
||||
'discord.com', 'cdn.discordapp.com', 'media.discordapp.net',
|
||||
'picsum.photos', 'images.unsplash.com',
|
||||
]);
|
||||
|
||||
function isTrustedDomain(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return TRUSTED_DOMAINS.has(parsed.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getSafeImageUrl(imageUrl) {
|
||||
if (!imageUrl) return null;
|
||||
if (isTrustedDomain(imageUrl)) return imageUrl;
|
||||
const signature = await signImageUrl(imageUrl);
|
||||
return `/api/image-proxy?url=${encodeURIComponent(imageUrl)}&sig=${signature}`;
|
||||
}
|
||||
|
||||
function parseMetaTags(html, url) {
|
||||
const meta = {
|
||||
url,
|
||||
title: null,
|
||||
description: null,
|
||||
image: null,
|
||||
siteName: null,
|
||||
type: null,
|
||||
video: null,
|
||||
themeColor: null,
|
||||
};
|
||||
|
||||
const decode = str => str?.replace(/&(#(?:x[0-9a-fA-F]+|\d+)|[a-zA-Z]+);/g,
|
||||
(_, e) => e[0]==='#' ? String.fromCharCode(e[1]==='x'?parseInt(e.slice(2),16):parseInt(e.slice(1),10))
|
||||
: ({amp:'&',lt:'<',gt:'>',quot:'"',apos:"'"}[e]||_));
|
||||
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
|
||||
// Open Graph / Twitter / fallback
|
||||
meta.title =
|
||||
decode(
|
||||
document.querySelector('meta[property="og:title"]')?.getAttribute('content') ||
|
||||
document.querySelector('meta[name="twitter:title"]')?.getAttribute('content') ||
|
||||
document.querySelector('title')?.textContent || null
|
||||
);
|
||||
|
||||
meta.description =
|
||||
decode(
|
||||
document.querySelector('meta[property="og:description"]')?.getAttribute('content') ||
|
||||
document.querySelector('meta[name="twitter:description"]')?.getAttribute('content') ||
|
||||
document.querySelector('meta[name="description"]')?.getAttribute('content') || null
|
||||
);
|
||||
|
||||
meta.image =
|
||||
decode(
|
||||
document.querySelector('meta[property="og:image"]')?.getAttribute('content') ||
|
||||
document.querySelector('meta[name="twitter:image"]')?.getAttribute('content') || null
|
||||
);
|
||||
|
||||
meta.siteName =
|
||||
decode(
|
||||
document.querySelector('meta[property="og:site_name"]')?.getAttribute('content') ||
|
||||
new URL(url).hostname.replace(/^www\./, '')
|
||||
);
|
||||
|
||||
meta.type =
|
||||
decode(
|
||||
document.querySelector('meta[property="og:type"]')?.getAttribute('content') || null
|
||||
);
|
||||
|
||||
meta.video =
|
||||
decode(
|
||||
document.querySelector('meta[property="og:video"]')?.getAttribute('content') || null
|
||||
);
|
||||
|
||||
meta.themeColor =
|
||||
decode(
|
||||
document.querySelector('meta[name="theme-color"]')?.getAttribute('content') || null
|
||||
);
|
||||
|
||||
// Resolve relative image URLs
|
||||
if (meta.image && !meta.image.startsWith('http')) {
|
||||
try {
|
||||
meta.image = decode(new URL(meta.image, new URL(url).origin).href);
|
||||
} catch {
|
||||
meta.image = null;
|
||||
}
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 10,
|
||||
windowMs: 1000,
|
||||
burstLimit: 50,
|
||||
burstWindowMs: 10_000,
|
||||
});
|
||||
|
||||
let cookieId = getCookieId(request);
|
||||
const hadCookie = !!cookieId;
|
||||
if (!cookieId) cookieId = generateNonce();
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
const errorMsg = rateCheck.isFlooding
|
||||
? { error: 'Too many requests - please slow down' }
|
||||
: { error: 'Rate limit exceeded' };
|
||||
const resp = new Response(JSON.stringify(errorMsg), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||
});
|
||||
if (!hadCookie) resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
return resp;
|
||||
}
|
||||
|
||||
recordRequest(request, 1000);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const targetUrl = url.searchParams.get('url');
|
||||
|
||||
if (!targetUrl) {
|
||||
return new Response(JSON.stringify({ error: 'Missing url parameter' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(targetUrl);
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) throw new Error();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid URL' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const trusted = isTrustedDomain(targetUrl);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; DiscordBot/2.0; +https://discordapp.com)',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
},
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Failed to fetch URL',
|
||||
status: response.status
|
||||
}), {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
// Handle direct image
|
||||
if (contentType.startsWith('image/')) {
|
||||
const safeImageUrl = await getSafeImageUrl(targetUrl);
|
||||
const result = { url: targetUrl, type: 'image', image: safeImageUrl, trusted };
|
||||
const resp = new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600' },
|
||||
});
|
||||
if (!hadCookie) resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
return resp;
|
||||
}
|
||||
|
||||
// Handle direct video
|
||||
if (contentType.startsWith('video/')) {
|
||||
if (!trusted) {
|
||||
return new Response(JSON.stringify({ error: 'Untrusted video source' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const result = { url: targetUrl, type: 'video', video: targetUrl, trusted };
|
||||
const resp = new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600' },
|
||||
});
|
||||
if (!hadCookie) resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
return resp;
|
||||
}
|
||||
|
||||
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) {
|
||||
return new Response(JSON.stringify({ error: 'URL is not an HTML page', contentType }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Read first 50KB
|
||||
const reader = response.body.getReader();
|
||||
let html = '';
|
||||
let bytesRead = 0;
|
||||
const maxBytes = 50 * 1024;
|
||||
|
||||
while (bytesRead < maxBytes) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
html += new TextDecoder().decode(value);
|
||||
bytesRead += value.length;
|
||||
if (html.includes('</head>')) break;
|
||||
}
|
||||
reader.cancel();
|
||||
|
||||
const meta = parseMetaTags(html, targetUrl);
|
||||
meta.trusted = trusted;
|
||||
|
||||
// Convert image to safe URL
|
||||
if (meta.image) meta.image = await getSafeImageUrl(meta.image);
|
||||
|
||||
const resp = new Response(JSON.stringify(meta), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600' },
|
||||
});
|
||||
if (!hadCookie) resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
return resp;
|
||||
|
||||
} catch (err) {
|
||||
console.error('[link-preview] Error fetching URL:', err.message);
|
||||
// Don't expose internal error details to client
|
||||
return new Response(JSON.stringify({ error: 'Failed to fetch preview' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
135
src/pages/api/search.js
Normal file
135
src/pages/api/search.js
Normal file
@@ -0,0 +1,135 @@
|
||||
import { getSubsiteByHost } from '../../utils/subsites.js';
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
getCookieId,
|
||||
generateNonce,
|
||||
createNonceCookie,
|
||||
getClientIp,
|
||||
} from '../../utils/rateLimit.js';
|
||||
|
||||
export async function GET({ request }) {
|
||||
const host = request.headers.get('host');
|
||||
const subsite = getSubsiteByHost(host);
|
||||
|
||||
if (!subsite || subsite.short !== 'req') {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
// Rate limit check (5 requests per second, flood protection at 30/10s)
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 5,
|
||||
windowMs: 1000,
|
||||
burstLimit: 30,
|
||||
burstWindowMs: 10_000,
|
||||
});
|
||||
|
||||
let cookieId = getCookieId(request);
|
||||
const hadCookie = !!cookieId;
|
||||
if (!cookieId) {
|
||||
cookieId = generateNonce();
|
||||
}
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
const errorMsg = rateCheck.isFlooding
|
||||
? { error: 'Too many requests - please slow down' }
|
||||
: { error: 'Rate limit exceeded' };
|
||||
const response = new Response(JSON.stringify(errorMsg), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '1',
|
||||
},
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
console.log(`[search] rate limited: ip=${rateCheck.ip} flooding=${rateCheck.isFlooding}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Record the request for rate limiting
|
||||
recordRequest(request, 1000);
|
||||
|
||||
const TMDB_API_KEY = import.meta.env.TMDB_API_KEY;
|
||||
|
||||
if (!TMDB_API_KEY) {
|
||||
console.error('TMDB_API_KEY not set');
|
||||
const response = new Response(JSON.stringify([]), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const q = url.searchParams.get('q');
|
||||
|
||||
if (!q || typeof q !== 'string' || !q.trim()) {
|
||||
const response = new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
if (q.length > 100) {
|
||||
const response = new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiResponse = await fetch(`https://api.themoviedb.org/3/search/multi?api_key=${TMDB_API_KEY}&query=${encodeURIComponent(q)}`);
|
||||
if (!apiResponse.ok) {
|
||||
throw new Error(`TMDB API error: ${apiResponse.status}`);
|
||||
}
|
||||
const data = await apiResponse.json();
|
||||
const seen = new Set();
|
||||
const filtered = data.results
|
||||
.filter(item => {
|
||||
if (item.media_type !== 'movie' && item.media_type !== 'tv') return false;
|
||||
const key = `${item.media_type}-${item.title || item.name}-${item.release_date?.split('-')[0] || item.first_air_date?.split('-')[0] || ''}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 10) // Limit to 10 suggestions
|
||||
.map(item => ({
|
||||
label: item.title || item.name,
|
||||
value: item.title || item.name,
|
||||
year: item.release_date?.split('-')[0] || item.first_air_date?.split('-')[0],
|
||||
mediaType: item.media_type,
|
||||
overview: item.overview,
|
||||
poster_path: item.poster_path,
|
||||
}));
|
||||
const response = new Response(JSON.stringify(filtered), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error fetching suggestions:', error);
|
||||
const response = new Response(JSON.stringify([]), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
254
src/pages/api/submit.js
Normal file
254
src/pages/api/submit.js
Normal file
@@ -0,0 +1,254 @@
|
||||
import { getSubsiteByHost } from '../../utils/subsites.js';
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordRequest as recordRateLimitRequest,
|
||||
getCookieId,
|
||||
generateNonce,
|
||||
createNonceCookie,
|
||||
getClientIp,
|
||||
} from '../../utils/rateLimit.js';
|
||||
|
||||
export async function POST({ request }) {
|
||||
const host = request.headers.get('host');
|
||||
const subsite = getSubsiteByHost(host);
|
||||
if (!subsite || subsite.short !== 'req') {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
// Rate limit check (1 request per 15 seconds, flood protection at 10/30s)
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 1,
|
||||
windowMs: 15_000,
|
||||
burstLimit: 10,
|
||||
burstWindowMs: 30_000,
|
||||
});
|
||||
|
||||
let cookieId = getCookieId(request);
|
||||
const hadCookie = !!cookieId;
|
||||
if (!cookieId) {
|
||||
cookieId = generateNonce();
|
||||
}
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
const errorMsg = rateCheck.isFlooding
|
||||
? { error: 'Too many requests - please slow down' }
|
||||
: { error: 'Rate limit exceeded. Please wait before submitting again.' };
|
||||
const response = new Response(JSON.stringify(errorMsg), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '15',
|
||||
},
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
console.log(`[submit] rate limited: ip=${rateCheck.ip} flooding=${rateCheck.isFlooding}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
const DISCORD_WEBHOOK_URL = import.meta.env.BUDO_REQ_DISCORD_WEBHOOK_URL;
|
||||
|
||||
if (!DISCORD_WEBHOOK_URL) {
|
||||
console.error('DISCORD_WEBHOOK_URL not set');
|
||||
const response = new Response(JSON.stringify({ error: 'Webhook not configured' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
const { title, year, type, requester } = await request.json();
|
||||
|
||||
// Input validation
|
||||
if (!title || typeof title !== 'string' || !title.trim()) {
|
||||
const response = new Response(JSON.stringify({ error: 'Title is required and must be a non-empty string' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
if (title.length > 200) {
|
||||
const response = new Response(JSON.stringify({ error: 'Title must be 200 characters or less' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
if (!['movie', 'tv'].includes(type)) {
|
||||
const response = new Response(JSON.stringify({ error: 'Type must be either "movie" or "tv"' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
if (year && (typeof year !== 'string' || !/^\d{4}$/.test(year))) {
|
||||
const response = new Response(JSON.stringify({ error: 'Year must be a 4-digit number if provided' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
if (requester && (typeof requester !== 'string' || requester.length > 500)) {
|
||||
const response = new Response(JSON.stringify({ error: 'Requester name must be 500 characters or less if provided' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// Fetch synopsis and IMDb ID from TMDB
|
||||
let synopsis = '';
|
||||
let imdbId = '';
|
||||
let matchingItem = null;
|
||||
try {
|
||||
const searchResponse = await fetch(`https://api.themoviedb.org/3/search/multi?api_key=${import.meta.env.TMDB_API_KEY}&query=${encodeURIComponent(title)}`);
|
||||
if (searchResponse.ok) {
|
||||
const searchData = await searchResponse.json();
|
||||
matchingItem = searchData.results.find(item =>
|
||||
item.media_type === type &&
|
||||
(item.title || item.name) === title
|
||||
);
|
||||
if (matchingItem) {
|
||||
synopsis = matchingItem.overview || '';
|
||||
|
||||
// Get detailed info for IMDb ID
|
||||
const detailEndpoint = type === 'movie' ? `movie/${matchingItem.id}` : `tv/${matchingItem.id}`;
|
||||
const detailResponse = await fetch(`https://api.themoviedb.org/3/${detailEndpoint}?api_key=${import.meta.env.TMDB_API_KEY}`);
|
||||
if (detailResponse.ok) {
|
||||
const detailData = await detailResponse.json();
|
||||
imdbId = detailData.imdb_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching synopsis and IMDb:', error);
|
||||
// Continue without synopsis
|
||||
}
|
||||
|
||||
const fields = [
|
||||
{
|
||||
name: "Title",
|
||||
value: title,
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: "Type",
|
||||
value: type === 'tv' ? 'TV Show' : 'Movie',
|
||||
inline: false
|
||||
}
|
||||
];
|
||||
|
||||
if (year) {
|
||||
fields.push({
|
||||
name: "Year",
|
||||
value: year,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
if (synopsis) {
|
||||
fields.push({
|
||||
name: "Synopsis",
|
||||
value: synopsis,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
if (imdbId) {
|
||||
fields.push({
|
||||
name: "IMDb",
|
||||
value: `[View on IMDb](https://www.imdb.com/title/${imdbId}/)`,
|
||||
inline: false
|
||||
});
|
||||
} else if (matchingItem) {
|
||||
const tmdbUrl = `https://www.themoviedb.org/${type}/${matchingItem.id}`;
|
||||
fields.push({
|
||||
name: "TMDB",
|
||||
value: `[View on TMDB](${tmdbUrl})`,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
if (requester && requester.trim()) {
|
||||
fields.push({
|
||||
name: "Requested by",
|
||||
value: requester.trim(),
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
const embed = {
|
||||
title: type === 'tv' ? "📺 New TV Show Request" : "🎥 New Movie Request",
|
||||
color: type === 'tv' ? 0x4ecdc4 : 0xff6b6b,
|
||||
fields: fields,
|
||||
timestamp: new Date().toISOString(),
|
||||
footer: {
|
||||
text: subsite.host || 'req.boatson.boats'
|
||||
}
|
||||
};
|
||||
|
||||
if (matchingItem && matchingItem.poster_path) {
|
||||
embed.image = { url: `https://image.tmdb.org/t/p/w780${matchingItem.poster_path}` };
|
||||
} else {
|
||||
// Test image
|
||||
embed.image = { url: 'https://image.tmdb.org/t/p/w780/9O7gLzmreU0nGkIB6K3BsJbzvNv.jpg' };
|
||||
}
|
||||
|
||||
const apiResponse = await fetch(DISCORD_WEBHOOK_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ embeds: [embed] }),
|
||||
});
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
throw new Error(`Discord webhook error: ${apiResponse.status}`);
|
||||
}
|
||||
|
||||
// Record the request for rate limiting after successful submission
|
||||
recordRateLimitRequest(request, 15_000);
|
||||
|
||||
const response = new Response(JSON.stringify({ success: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Webhook submission error:', error);
|
||||
const response = new Response(JSON.stringify({ error: 'Failed to submit' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
24
src/pages/debug.astro
Normal file
24
src/pages/debug.astro
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
---
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Debug</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Debug</h1>
|
||||
<ul>
|
||||
<li>Host: {Astro.request.headers.get('host')}</li>
|
||||
<li>Path: {Astro.url.pathname}</li>
|
||||
<li>Is dev: {String(import.meta.env.DEV)}</li>
|
||||
</ul>
|
||||
<h2>Headers</h2>
|
||||
<ul>
|
||||
{Array.from(Astro.request.headers.entries()).map(([k, v]) => (
|
||||
<li>{k}: {v}</li>
|
||||
))}
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
26
src/pages/discord-logs.astro
Normal file
26
src/pages/discord-logs.astro
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
import Root from "../components/AppLayout.jsx";
|
||||
import "@styles/DiscordLogs.css";
|
||||
|
||||
// Auth + role check handled by middleware
|
||||
// Middleware redirects to /login if not authenticated or lacks 'discord' role
|
||||
const user = Astro.locals.user as any;
|
||||
|
||||
// Prevent browser caching of authenticated pages
|
||||
Astro.response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
|
||||
Astro.response.headers.set('Pragma', 'no-cache');
|
||||
---
|
||||
|
||||
<Base title="Discord Archive" description="Archived Discord channel logs">
|
||||
<section class="page-section discord-logs-section">
|
||||
<Root child="DiscordLogs" client:only="react" />
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style is:global>
|
||||
/* Override main container width for Discord logs */
|
||||
body:has(.discord-logs-section) main.page-enter {
|
||||
max-width: 1400px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -2,15 +2,26 @@
|
||||
import Base from "../layouts/Base.astro";
|
||||
import Root from "../components/AppLayout.jsx";
|
||||
import LyricSearch from '../components/LyricSearch.jsx';
|
||||
|
||||
const hostHeader = Astro.request?.headers?.get('host') || '';
|
||||
const host = hostHeader.split(':')[0];
|
||||
import { getSubsiteByHost } from '../utils/subsites.js';
|
||||
import { getSubsiteByPath } from '../utils/subsites.js';
|
||||
const detected = getSubsiteByHost(host) ?? getSubsiteByPath(Astro.url.pathname) ?? null;
|
||||
const isReq = detected?.short === 'req' || getSubsiteByPath(Astro.url.pathname)?.short === 'req';
|
||||
|
||||
import { WHITELABELS } from "../config";
|
||||
const whitelabel = WHITELABELS[host] ?? (detected ? WHITELABELS[detected.host] : null);
|
||||
---
|
||||
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root
|
||||
child="LyricSearch"
|
||||
client:only="react"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{whitelabel ? (
|
||||
<section class="page-section">
|
||||
<Root child="ReqForm" client:only="react" />
|
||||
</section>
|
||||
) : (
|
||||
<section class="page-section">
|
||||
<Root child="LyricSearch" client:only="react" />
|
||||
</section>
|
||||
)}
|
||||
</Base>
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
---
|
||||
import Base from "@/layouts/Base.astro";
|
||||
import Root from "@/components/AppLayout.jsx";
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
|
||||
const user = await requireAuthHook(Astro);
|
||||
|
||||
if (!user || !user.roles.includes('lighting')) {
|
||||
return Astro.redirect('/login');
|
||||
}
|
||||
// Auth + role check handled by middleware
|
||||
// Middleware redirects to /login if not authenticated or lacks 'lighting' role
|
||||
const user = Astro.locals.user as any;
|
||||
---
|
||||
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="Lighting" user?={user} client:only="react" />
|
||||
</div>
|
||||
<section class="page-section">
|
||||
<Root child="Lighting" user?={user} client:only="react" />
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
@@ -8,9 +8,7 @@ const isLoggedIn = Boolean(user);
|
||||
|
||||
---
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="LoginPage" loggedIn={isLoggedIn} client:only="react" >
|
||||
</Root>
|
||||
<section class="page-section">
|
||||
<Root child="LoginPage" loggedIn={isLoggedIn} client:only="react" />
|
||||
</section>
|
||||
</Base>
|
||||
@@ -4,10 +4,8 @@ import Root from "../components/AppLayout.jsx";
|
||||
import "@styles/MemeGrid.css";
|
||||
---
|
||||
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="Memes" client:only="react">
|
||||
</Root>
|
||||
<Base hideFooter>
|
||||
<section class="page-section">
|
||||
<Root child="Memes" client:only="react" />
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
import Root from "../components/AppLayout.jsx";
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
const user = await requireAuthHook(Astro);
|
||||
|
||||
// Auth handled by middleware - user available in Astro.locals.user
|
||||
const user = Astro.locals.user as any;
|
||||
---
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="Player" user={user} client:only="react">
|
||||
</Root>
|
||||
<section class="page-section">
|
||||
<Root child="Player" user={user} client:only="react" />
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
8
src/pages/subsites/req/debug.astro
Normal file
8
src/pages/subsites/req/debug.astro
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import Base from "../../../layouts/Base.astro";
|
||||
---
|
||||
|
||||
<Base>
|
||||
<h2>Req subsite debug</h2>
|
||||
<pre>{JSON.stringify({ headers: Object.fromEntries(Astro.request.headers.entries()), pathname: Astro.url.pathname }, null, 2)}</pre>
|
||||
</Base>
|
||||
8
src/pages/subsites/req/index.astro
Normal file
8
src/pages/subsites/req/index.astro
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import Base from "../../../layouts/Base.astro";
|
||||
import ReqForm from "../../../components/req/ReqForm.jsx";
|
||||
---
|
||||
|
||||
<Base>
|
||||
<ReqForm client:load />
|
||||
</Base>
|
||||
117
src/utils/apiAuth.js
Normal file
117
src/utils/apiAuth.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* API route authentication helper
|
||||
* Validates user session for protected API endpoints
|
||||
*/
|
||||
import { API_URL } from '@/config';
|
||||
|
||||
/**
|
||||
* Check if the request has a valid authentication session
|
||||
* @param {Request} request - The incoming request
|
||||
* @returns {Promise<{user: object|null, error: Response|null, setCookieHeader: string|null}>}
|
||||
*/
|
||||
export async function requireApiAuth(request) {
|
||||
try {
|
||||
const cookieHeader = request.headers.get('cookie') ?? '';
|
||||
|
||||
if (!cookieHeader) {
|
||||
return {
|
||||
user: null,
|
||||
error: new Response(JSON.stringify({ error: 'Authentication required' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
setCookieHeader: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Try to get user identity
|
||||
let res = await fetch(`${API_URL}/auth/id`, {
|
||||
headers: { Cookie: cookieHeader },
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
let newSetCookieHeader = null;
|
||||
|
||||
// If unauthorized, try to refresh the token
|
||||
if (res.status === 401) {
|
||||
const refreshRes = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookieHeader },
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!refreshRes.ok) {
|
||||
return {
|
||||
user: null,
|
||||
error: new Response(JSON.stringify({ error: 'Session expired' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
setCookieHeader: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Capture the Set-Cookie header from the refresh response to forward to client
|
||||
newSetCookieHeader = refreshRes.headers.get('set-cookie');
|
||||
|
||||
let newCookieHeader = cookieHeader;
|
||||
|
||||
if (newSetCookieHeader) {
|
||||
const cookiesArray = newSetCookieHeader.split(/,(?=\s*\w+=)/);
|
||||
newCookieHeader = cookiesArray.map(c => c.split(';')[0]).join('; ');
|
||||
} else {
|
||||
return {
|
||||
user: null,
|
||||
error: new Response(JSON.stringify({ error: 'Session refresh failed' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
setCookieHeader: null,
|
||||
};
|
||||
}
|
||||
|
||||
res = await fetch(`${API_URL}/auth/id`, {
|
||||
headers: { Cookie: newCookieHeader },
|
||||
credentials: 'include',
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
user: null,
|
||||
error: new Response(JSON.stringify({ error: 'Authentication failed' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
setCookieHeader: null,
|
||||
};
|
||||
}
|
||||
|
||||
const user = await res.json();
|
||||
return { user, error: null, setCookieHeader: newSetCookieHeader };
|
||||
} catch (err) {
|
||||
console.error('API auth error:', err);
|
||||
return {
|
||||
user: null,
|
||||
error: new Response(JSON.stringify({ error: 'Authentication error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
setCookieHeader: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a response with optional Set-Cookie header forwarding
|
||||
* @param {any} data - Response data
|
||||
* @param {number} status - HTTP status code
|
||||
* @param {string|null} setCookieHeader - Set-Cookie header from auth refresh
|
||||
*/
|
||||
export function createApiResponse(data, status = 200, setCookieHeader = null) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (setCookieHeader) {
|
||||
headers['Set-Cookie'] = setCookieHeader;
|
||||
}
|
||||
return new Response(JSON.stringify(data), { status, headers });
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { API_URL } from "@/config";
|
||||
|
||||
// Track in-flight refresh to avoid duplicate requests
|
||||
let refreshPromise = null;
|
||||
let lastRefreshTime = 0;
|
||||
const REFRESH_COOLDOWN = 2000; // 2 second cooldown between refreshes
|
||||
|
||||
// Auth fetch wrapper
|
||||
export const authFetch = async (url, options = {}, retry = true) => {
|
||||
@@ -9,14 +13,11 @@ export const authFetch = async (url, options = {}, retry = true) => {
|
||||
});
|
||||
|
||||
if (res.status === 401 && retry) {
|
||||
// attempt refresh
|
||||
// attempt refresh (non-blocking if already in progress)
|
||||
try {
|
||||
const refreshRes = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const refreshSuccess = await doRefresh();
|
||||
|
||||
if (!refreshRes.ok) throw new Error("Refresh failed");
|
||||
if (!refreshSuccess) throw new Error("Refresh failed");
|
||||
|
||||
// Retry original request once after refresh
|
||||
return authFetch(url, options, false);
|
||||
@@ -29,6 +30,44 @@ export const authFetch = async (url, options = {}, retry = true) => {
|
||||
return res;
|
||||
};
|
||||
|
||||
// Centralized refresh function that handles deduplication properly
|
||||
async function doRefresh() {
|
||||
const now = Date.now();
|
||||
|
||||
// If a refresh just succeeded recently, assume we're good
|
||||
if (now - lastRefreshTime < REFRESH_COOLDOWN && !refreshPromise) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reuse existing refresh promise if one is in flight
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const refreshRes = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (refreshRes.ok) {
|
||||
lastRefreshTime = Date.now();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.error("Refresh request failed:", err);
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
// Clear the promise after it resolves
|
||||
refreshPromise.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
// Refresh token function (HttpOnly cookie flow)
|
||||
export async function refreshAccessToken(cookieHeader) {
|
||||
try {
|
||||
@@ -51,6 +90,48 @@ export async function refreshAccessToken(cookieHeader) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure authentication is valid before making API requests.
|
||||
* Makes a lightweight auth check against our own API and refreshes if needed.
|
||||
* Returns true if auth is valid, false if user needs to log in.
|
||||
*/
|
||||
export async function ensureAuth() {
|
||||
try {
|
||||
// Try a lightweight request to our own API that requires auth
|
||||
// Using HEAD or a simple endpoint to minimize overhead
|
||||
const res = await fetch('/api/discord/channels', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
// Try to refresh the token using our centralized refresh handler
|
||||
const refreshSuccess = await doRefresh();
|
||||
|
||||
if (refreshSuccess) {
|
||||
// Retry the auth check after refresh
|
||||
const retryRes = await fetch('/api/discord/channels', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
return retryRes.ok;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Other errors (500, etc.) - assume auth is OK but server issue
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Auth check failed:', err);
|
||||
// Network error - don't redirect, let the actual API calls handle it
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleLogout() {
|
||||
document.cookie.split(";").forEach((cookie) => {
|
||||
const name = cookie.split("=")[0].trim();
|
||||
|
||||
18
src/utils/db.js
Normal file
18
src/utils/db.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* PostgreSQL database connection for Discord logs
|
||||
*/
|
||||
import postgres from 'postgres';
|
||||
|
||||
// Database connection configuration
|
||||
const sql = postgres({
|
||||
host: import.meta.env.DISCORD_DB_HOST || 'localhost',
|
||||
port: parseInt(import.meta.env.DISCORD_DB_PORT || '5432', 10),
|
||||
database: import.meta.env.DISCORD_DB_NAME || 'discord',
|
||||
username: import.meta.env.DISCORD_DB_USER || 'discord',
|
||||
password: import.meta.env.DISCORD_DB_PASSWORD || '',
|
||||
max: 10, // Max connections in pool
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
|
||||
export default sql;
|
||||
@@ -3,14 +3,30 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const secretFilePath = path.join(
|
||||
// JWT keys location - can be configured via environment variable
|
||||
// In production, prefer using a secret management service (Vault, AWS Secrets Manager, etc.)
|
||||
const secretFilePath = import.meta.env.JWT_KEYS_PATH || path.join(
|
||||
os.homedir(),
|
||||
'.config',
|
||||
'api_jwt_keys.json'
|
||||
);
|
||||
|
||||
// Warn if using default location in production
|
||||
if (!import.meta.env.JWT_KEYS_PATH && !import.meta.env.DEV) {
|
||||
console.warn(
|
||||
'[SECURITY WARNING] JWT_KEYS_PATH not set. Using default location ~/.config/api_jwt_keys.json. ' +
|
||||
'Consider using a secret management service in production.'
|
||||
);
|
||||
}
|
||||
|
||||
// Load and parse keys JSON once at startup
|
||||
const keyFileData = JSON.parse(fs.readFileSync(secretFilePath, 'utf-8'));
|
||||
let keyFileData;
|
||||
try {
|
||||
keyFileData = JSON.parse(fs.readFileSync(secretFilePath, 'utf-8'));
|
||||
} catch (err) {
|
||||
console.error(`[CRITICAL] Failed to load JWT keys from ${secretFilePath}:`, err.message);
|
||||
throw new Error('JWT keys file not found or invalid. Set JWT_KEYS_PATH environment variable.');
|
||||
}
|
||||
|
||||
export function verifyToken(token) {
|
||||
if (!token) {
|
||||
|
||||
275
src/utils/rateLimit.js
Normal file
275
src/utils/rateLimit.js
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Robust rate limiter using both IP address and cookie-based tracking.
|
||||
* Implements sliding window with burst protection and automatic cleanup.
|
||||
*/
|
||||
|
||||
// Separate maps for IP and cookie tracking
|
||||
const ipRateLimitMap = new Map();
|
||||
const cookieRateLimitMap = new Map();
|
||||
|
||||
// Global flood protection - track overall request volume per IP
|
||||
const floodProtectionMap = new Map();
|
||||
|
||||
// Cleanup old entries every 60 seconds to prevent memory leaks
|
||||
const CLEANUP_INTERVAL = 60_000;
|
||||
let lastCleanup = Date.now();
|
||||
|
||||
// Trusted proxy configuration - only trust proxy headers from known sources
|
||||
// Set TRUSTED_PROXY_IPS env var to comma-separated list of IPs/CIDRs
|
||||
// If behind Cloudflare, Vercel, or similar, their proxy IPs are implicitly trusted
|
||||
const TRUSTED_PROXIES = new Set(
|
||||
(import.meta.env.TRUSTED_PROXY_IPS || '')
|
||||
.split(',')
|
||||
.map(ip => ip.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
// Common cloud provider proxy indicators
|
||||
const VERCEL_INDICATOR = import.meta.env.VERCEL === '1';
|
||||
const CLOUDFLARE_INDICATOR = typeof import.meta.env.CF_PAGES !== 'undefined' ||
|
||||
typeof import.meta.env.CF_PAGES_URL !== 'undefined';
|
||||
|
||||
/**
|
||||
* Check if request is from a trusted proxy.
|
||||
* In production behind Vercel/Cloudflare, proxy headers are trustworthy.
|
||||
*/
|
||||
function isTrustedProxy(request) {
|
||||
// If running on Vercel or Cloudflare, trust their headers
|
||||
if (VERCEL_INDICATOR || CLOUDFLARE_INDICATOR) return true;
|
||||
|
||||
// If specific trusted proxies are configured, check them
|
||||
if (TRUSTED_PROXIES.size > 0) {
|
||||
// In a real deployment, you'd check the connecting IP against trusted IPs
|
||||
// For now, if TRUSTED_PROXY_IPS is set, we trust the headers
|
||||
return true;
|
||||
}
|
||||
|
||||
// Default: don't trust proxy headers (direct connection)
|
||||
return false;
|
||||
}
|
||||
|
||||
function cleanupStaleEntries() {
|
||||
const now = Date.now();
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL) return;
|
||||
lastCleanup = now;
|
||||
|
||||
for (const [key, entry] of ipRateLimitMap) {
|
||||
if (now > entry.resetTime + 60_000) ipRateLimitMap.delete(key);
|
||||
}
|
||||
for (const [key, entry] of cookieRateLimitMap) {
|
||||
if (now > entry.resetTime + 60_000) cookieRateLimitMap.delete(key);
|
||||
}
|
||||
for (const [key, entry] of floodProtectionMap) {
|
||||
if (now > entry.resetTime + 60_000) floodProtectionMap.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract client IP from request headers with proxy support.
|
||||
* Only trusts proxy headers when behind a known/configured proxy.
|
||||
*/
|
||||
export function getClientIp(request) {
|
||||
const headers = request.headers;
|
||||
|
||||
// Only trust proxy headers if we're behind a trusted proxy
|
||||
if (isTrustedProxy(request)) {
|
||||
// Cloudflare's header is most reliable when using Cloudflare
|
||||
const cfConnectingIp = headers.get('cf-connecting-ip');
|
||||
if (cfConnectingIp && isValidIp(cfConnectingIp)) return normalizeIp(cfConnectingIp);
|
||||
|
||||
// Vercel/standard proxy header
|
||||
const xForwardedFor = headers.get('x-forwarded-for');
|
||||
if (xForwardedFor) {
|
||||
// Take first IP (original client), trim whitespace
|
||||
const ip = xForwardedFor.split(',')[0].trim();
|
||||
if (ip && isValidIp(ip)) return normalizeIp(ip);
|
||||
}
|
||||
|
||||
const xRealIp = headers.get('x-real-ip');
|
||||
if (xRealIp && isValidIp(xRealIp)) return normalizeIp(xRealIp);
|
||||
|
||||
const trueClientIp = headers.get('true-client-ip');
|
||||
if (trueClientIp && isValidIp(trueClientIp)) return normalizeIp(trueClientIp);
|
||||
}
|
||||
|
||||
// Fallback - in production this typically means misconfiguration
|
||||
// Log this case in development to catch configuration issues
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[RateLimit] Could not determine client IP - proxy headers not trusted');
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic IP validation to prevent header injection attacks.
|
||||
*/
|
||||
function isValidIp(ip) {
|
||||
if (!ip || typeof ip !== 'string') return false;
|
||||
// Basic sanity check - no weird characters, reasonable length
|
||||
if (ip.length > 45) return false; // Max IPv6 length
|
||||
if (!/^[\d.:a-fA-F]+$/.test(ip)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize IP for consistent keying (lowercase, trim).
|
||||
*/
|
||||
function normalizeIp(ip) {
|
||||
return ip.toLowerCase().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract nonce cookie from request.
|
||||
*/
|
||||
export function getCookieId(request) {
|
||||
const cookieHeader = request.headers.get('cookie');
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const match = cookieHeader.split(';').find(c => c.trim().startsWith('nonce='));
|
||||
if (!match) return null;
|
||||
|
||||
const value = match.split('=')[1]?.trim();
|
||||
// Validate UUID format loosely
|
||||
if (!value || value.length < 32 || value.length > 40) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an identifier is rate limited.
|
||||
*/
|
||||
function isLimited(map, key, limit, windowMs) {
|
||||
const now = Date.now();
|
||||
const entry = map.get(key);
|
||||
|
||||
if (!entry || now > entry.resetTime) {
|
||||
return false; // Not limited, window expired or new
|
||||
}
|
||||
|
||||
return entry.count >= limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a request for rate limiting.
|
||||
*/
|
||||
function recordHit(map, key, windowMs) {
|
||||
const now = Date.now();
|
||||
const entry = map.get(key);
|
||||
|
||||
if (!entry || now > entry.resetTime) {
|
||||
map.set(key, { count: 1, resetTime: now + windowMs });
|
||||
} else {
|
||||
entry.count++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current count for an identifier.
|
||||
*/
|
||||
function getCount(map, key) {
|
||||
const now = Date.now();
|
||||
const entry = map.get(key);
|
||||
if (!entry || now > entry.resetTime) return 0;
|
||||
return entry.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check flood protection - aggressive rate limit for potential abuse.
|
||||
* This triggers when an IP sends too many requests in a short burst.
|
||||
*/
|
||||
function checkFloodProtection(ip, burstLimit = 30, burstWindowMs = 10_000) {
|
||||
if (ip === 'unknown') return false; // Can't flood-protect unknown IPs effectively
|
||||
|
||||
const now = Date.now();
|
||||
const entry = floodProtectionMap.get(ip);
|
||||
|
||||
if (!entry || now > entry.resetTime) {
|
||||
floodProtectionMap.set(ip, { count: 1, resetTime: now + burstWindowMs });
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return entry.count > burstLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main rate limit check combining IP and cookie tracking.
|
||||
*
|
||||
* @param {Request} request - The incoming request
|
||||
* @param {Object} options - Rate limit configuration
|
||||
* @param {number} options.limit - Max requests per window
|
||||
* @param {number} options.windowMs - Window duration in ms
|
||||
* @param {number} options.burstLimit - Flood protection burst limit
|
||||
* @param {number} options.burstWindowMs - Flood protection window
|
||||
* @returns {{ allowed: boolean, ip: string, cookieId: string|null, isFlooding: boolean }}
|
||||
*/
|
||||
export function checkRateLimit(request, options = {}) {
|
||||
const {
|
||||
limit = 5,
|
||||
windowMs = 1000,
|
||||
burstLimit = 30,
|
||||
burstWindowMs = 10_000,
|
||||
} = options;
|
||||
|
||||
cleanupStaleEntries();
|
||||
|
||||
const ip = getClientIp(request);
|
||||
const cookieId = getCookieId(request);
|
||||
|
||||
// Check flood protection first (aggressive anti-abuse)
|
||||
const isFlooding = checkFloodProtection(ip, burstLimit, burstWindowMs);
|
||||
if (isFlooding) {
|
||||
return { allowed: false, ip, cookieId, isFlooding: true };
|
||||
}
|
||||
|
||||
// Check both IP and cookie limits
|
||||
// Both must be under limit to allow the request
|
||||
const ipLimited = ip !== 'unknown' && isLimited(ipRateLimitMap, ip, limit, windowMs);
|
||||
const cookieLimited = cookieId && isLimited(cookieRateLimitMap, cookieId, limit, windowMs);
|
||||
|
||||
// If either is limited, deny
|
||||
if (ipLimited || cookieLimited) {
|
||||
return { allowed: false, ip, cookieId, isFlooding: false };
|
||||
}
|
||||
|
||||
return { allowed: true, ip, cookieId, isFlooding: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful request for rate limiting tracking.
|
||||
* Call this after the request is processed.
|
||||
*/
|
||||
export function recordRequest(request, windowMs = 1000) {
|
||||
const ip = getClientIp(request);
|
||||
const cookieId = getCookieId(request);
|
||||
|
||||
if (ip !== 'unknown') {
|
||||
recordHit(ipRateLimitMap, ip, windowMs);
|
||||
}
|
||||
if (cookieId) {
|
||||
recordHit(cookieRateLimitMap, cookieId, windowMs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new nonce cookie value.
|
||||
*/
|
||||
export function generateNonce() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Set-Cookie header value for nonce.
|
||||
*/
|
||||
export function createNonceCookie(nonce) {
|
||||
return `nonce=${nonce}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=31536000`;
|
||||
}
|
||||
|
||||
export default {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
getClientIp,
|
||||
getCookieId,
|
||||
generateNonce,
|
||||
createNonceCookie,
|
||||
};
|
||||
62
src/utils/subsites.js
Normal file
62
src/utils/subsites.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { SUBSITES, WHITELABELS } from '../config.js';
|
||||
|
||||
// Returns normalized host (no port)
|
||||
function normalizeHost(host = '') {
|
||||
if (!host) return '';
|
||||
return host.split(':')[0].toLowerCase();
|
||||
}
|
||||
|
||||
const HOSTS = Object.keys(SUBSITES || {});
|
||||
|
||||
export function getSubsiteByHost(rawHost = '') {
|
||||
const host = normalizeHost(rawHost || '');
|
||||
if (!host) return null;
|
||||
if (SUBSITES[host]) return { host, path: SUBSITES[host], short: host.split('.')[0] };
|
||||
// fallback: if short-name match
|
||||
const short = host.split('.')[0];
|
||||
const hostKey = HOSTS.find(h => h.split('.')[0] === short);
|
||||
if (hostKey) return { host: hostKey, path: SUBSITES[hostKey], short };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getSubsiteFromSignal(signal = '') {
|
||||
if (!signal) return null;
|
||||
// signal can be 'req' or 'req.boatson.boats'
|
||||
const val = signal.split(':')[0].split('?')[0];
|
||||
const short = val.split('.')[0];
|
||||
// direct host match
|
||||
if (SUBSITES[val]) return { host: val, path: SUBSITES[val], short };
|
||||
// short name match
|
||||
const hostKey = HOSTS.find(h => h.split('.')[0] === short);
|
||||
if (hostKey) return { host: hostKey, path: SUBSITES[hostKey], short };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getSubsiteByPath(path = '') {
|
||||
if (!path) return null;
|
||||
// check if path starts with one of the SUBSITES values
|
||||
const candidate = Object.entries(SUBSITES || {}).find(([, p]) => path.startsWith(p));
|
||||
if (!candidate) return null;
|
||||
const [hostKey, p] = candidate;
|
||||
return { host: hostKey, path: p, short: hostKey.split('.')[0] };
|
||||
}
|
||||
|
||||
export function isSubsiteHost(rawHost = '', shortName = '') {
|
||||
const h = getSubsiteByHost(rawHost);
|
||||
if (!h) return false;
|
||||
if (!shortName) return true;
|
||||
return h.short === shortName || h.host === shortName;
|
||||
}
|
||||
|
||||
export function getWhitelabelForHost(rawHost = '') {
|
||||
const info = getSubsiteByHost(rawHost);
|
||||
if (!info) return null;
|
||||
return WHITELABELS[info.host] ?? null;
|
||||
}
|
||||
|
||||
export default {
|
||||
getSubsiteByHost,
|
||||
getSubsiteFromSignal,
|
||||
isSubsiteHost,
|
||||
getWhitelabelForHost,
|
||||
};
|
||||
Reference in New Issue
Block a user