feat(api): implement rate limiting and SSRF protection across endpoints

- Added rate limiting to `reaction-users`, `search`, and `image-proxy` APIs to prevent abuse.
- Introduced SSRF protection in `image-proxy` to block requests to private IP ranges.
- Enhanced `link-preview` to use `linkedom` for HTML parsing and improved meta tag extraction.
- Refactored authentication checks in various pages to utilize middleware for cleaner code.
- Improved JWT key loading with error handling and security warnings for production.
- Updated `authFetch` utility to handle token refresh more efficiently with deduplication.
- Enhanced rate limiting utility to trust proxy headers from known sources.
- Numerous layout / design changes
This commit is contained in:
2025-12-05 14:21:52 -05:00
parent 55e4c5ff0c
commit e18aa3f42c
44 changed files with 3512 additions and 892 deletions

View File

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