mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(web): skip empty Authorization header for forward-auth proxy compatibility
Centralize duplicated getToken() + Bearer header logic into a single formatHeaders() helper in lib/api.ts. When no token exists, the Authorization header is omitted entirely instead of sending an empty Bearer token, which breaks forward-auth proxies like Authelia behind Caddy. Changes: - Add formatHeaders() with try-catch around localStorage access - Replace 20+ duplicated getToken() definitions across tool components - Migrate all call sites including file-details, settings, change-password - Update tests to verify header omission on empty token Based on the fix proposed by @jules2689 in #6, with improvements: file placement (lib/api.ts vs components), localStorage error handling, simplified truthiness check, and complete call-site coverage. Co-Authored-By: Julian Nadeau <julian@jnadeau.ca>
This commit is contained in:
co-authored by
Julian Nadeau
parent
f21579c7a3
commit
d0c69d6a46
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
apiGetFileDetails,
|
||||
formatHeaders,
|
||||
getFileDownloadUrl,
|
||||
getFileThumbnailUrl,
|
||||
type UserFileDetail,
|
||||
@@ -57,11 +58,10 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
|
||||
? allFiles.filter((f) => checkedIds.has(f.id))
|
||||
: [{ id: details.id, originalName: details.originalName, mimeType: details.mimeType }];
|
||||
|
||||
const token = localStorage.getItem("stirling-token") || "";
|
||||
const downloaded = await Promise.all(
|
||||
filesToOpen.map(async (f) => {
|
||||
const res = await fetch(getFileDownloadUrl(f.id), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const blob = await res.blob();
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { apiDelete, apiGet, apiPost, apiPut, clearToken } from "@/lib/api";
|
||||
import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api";
|
||||
import { cn, copyToClipboard } from "@/lib/utils";
|
||||
import { GemLogo } from "../common/gem-logo";
|
||||
|
||||
@@ -287,10 +287,9 @@ function SystemSection() {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
try {
|
||||
const token = localStorage.getItem("stirling-token");
|
||||
await fetch("/api/v1/settings/logo", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
setSettings((prev) => ({ ...prev, customLogo: "true" }));
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { copyToClipboard } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function BarcodeReadSettings() {
|
||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||
const [result, setResult] = useState<{ found: boolean; text: string | null } | null>(null);
|
||||
@@ -25,7 +21,7 @@ export function BarcodeReadSettings() {
|
||||
|
||||
const res = await fetch("/api/v1/tools/barcode-read", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function BulkRenameSettings() {
|
||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||
const [pattern, setPattern] = useState("image-{{index}}");
|
||||
@@ -28,7 +24,7 @@ export function BulkRenameSettings() {
|
||||
|
||||
const res = await fetch("/api/v1/tools/bulk-rename", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
type Layout = "2x2" | "3x3" | "1x3" | "2x1" | "3x1" | "1x2";
|
||||
|
||||
const LAYOUTS: { value: Layout; label: string }[] = [
|
||||
@@ -43,7 +40,7 @@ export function CollageSettings() {
|
||||
|
||||
const res = await fetch("/api/v1/tools/collage", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { copyToClipboard } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function ColorPaletteSettings() {
|
||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||
const [colors, setColors] = useState<string[]>([]);
|
||||
@@ -25,7 +21,7 @@ export function ColorPaletteSettings() {
|
||||
|
||||
const res = await fetch("/api/v1/tools/color-palette", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Download, Loader2, Upload } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function CompareSettings() {
|
||||
const { files, processing, error, setProcessing, setError, setProcessedUrl } = useFileStore();
|
||||
const [secondFile, setSecondFile] = useState<File | null>(null);
|
||||
@@ -28,7 +24,7 @@ export function CompareSettings() {
|
||||
|
||||
const res = await fetch("/api/v1/tools/compare", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Download, Loader2, Upload } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function ComposeSettings() {
|
||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
||||
useFileStore();
|
||||
@@ -34,7 +30,7 @@ export function ComposeSettings() {
|
||||
|
||||
const res = await fetch("/api/v1/tools/compose", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { Download, Redo, Trash2 } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import type { EraserCanvasRef } from "./eraser-canvas";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
interface EraseObjectSettingsProps {
|
||||
eraserRef: React.RefObject<EraserCanvasRef | null>;
|
||||
hasStrokes: boolean;
|
||||
@@ -114,7 +111,9 @@ export function EraseObjectSettings({
|
||||
setProgressPhase("idle");
|
||||
};
|
||||
xhr.open("POST", "/api/v1/tools/erase-object");
|
||||
xhr.setRequestHeader("Authorization", `Bearer ${getToken()}`);
|
||||
formatHeaders().forEach((value, key) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
xhr.send(formData);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
const SIZES = [
|
||||
{ name: "favicon-16x16.png", size: "16x16" },
|
||||
{ name: "favicon-32x32.png", size: "32x32" },
|
||||
@@ -33,7 +30,7 @@ export function FaviconSettings() {
|
||||
|
||||
const res = await fetch("/api/v1/tools/favicon", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
interface DuplicateGroup {
|
||||
files: Array<{ filename: string; similarity: number }>;
|
||||
}
|
||||
@@ -35,7 +32,7 @@ export function FindDuplicatesSettings() {
|
||||
|
||||
const res = await fetch("/api/v1/tools/find-duplicates", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
const PAGE_SIZES: Record<string, [number, number]> = {
|
||||
@@ -108,11 +109,6 @@ function PdfPagePreview({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function ImageToPdfSettings() {
|
||||
const { files, selectedIndex, processing, error, setProcessing, setError } = useFileStore();
|
||||
const [pageSize, setPageSize] = useState<"A4" | "Letter" | "A3" | "A5">("A4");
|
||||
@@ -136,7 +132,7 @@ export function ImageToPdfSettings() {
|
||||
|
||||
const res = await fetch("/api/v1/tools/image-to-pdf", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
interface ImageInfoData {
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
@@ -50,7 +47,7 @@ export function InfoSettings() {
|
||||
|
||||
const res = await fetch("/api/v1/tools/info", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { copyToClipboard, generateId } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
type OcrEngine = "tesseract" | "paddleocr";
|
||||
|
||||
const LANGUAGES = [
|
||||
@@ -112,7 +109,9 @@ export function OcrSettings() {
|
||||
setProgressPhase("idle");
|
||||
};
|
||||
xhr.open("POST", "/api/v1/tools/ocr");
|
||||
xhr.setRequestHeader("Authorization", `Bearer ${getToken()}`);
|
||||
formatHeaders().forEach((value, key) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
xhr.send(formData);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
export function QrGenerateSettings() {
|
||||
const [text, setText] = useState("");
|
||||
const [size, setSize] = useState(400);
|
||||
@@ -27,10 +23,7 @@ export function QrGenerateSettings() {
|
||||
try {
|
||||
const res = await fetch("/api/v1/tools/qr-generate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
},
|
||||
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ text, size, errorCorrection, foreground, background }),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function SplitSettings() {
|
||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||
const [columns, setColumns] = useState(2);
|
||||
@@ -26,7 +22,7 @@ export function SplitSettings() {
|
||||
|
||||
const res = await fetch("/api/v1/tools/split", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,12 +2,9 @@ import { AlertTriangle, ChevronDown, ChevronRight, Download, Loader2, MapPin } f
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
interface MetadataResult {
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
@@ -342,7 +339,7 @@ export function StripMetadataSettings() {
|
||||
formData.append("file", currentFile);
|
||||
const res = await fetch("/api/v1/tools/strip-metadata/inspect", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function SvgToRasterSettings() {
|
||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
||||
useFileStore();
|
||||
@@ -38,7 +34,7 @@ export function SvgToRasterSettings() {
|
||||
|
||||
const res = await fetch("/api/v1/tools/svg-to-raster", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function VectorizeSettings() {
|
||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
||||
useFileStore();
|
||||
@@ -30,7 +26,7 @@ export function VectorizeSettings() {
|
||||
|
||||
const res = await fetch("/api/v1/tools/vectorize", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { Download, Loader2, Upload } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function WatermarkImageSettings() {
|
||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
||||
useFileStore();
|
||||
@@ -35,7 +31,7 @@ export function WatermarkImageSettings() {
|
||||
|
||||
const res = await fetch("/api/v1/tools/watermark-image", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
|
||||
interface AuthState {
|
||||
loading: boolean;
|
||||
@@ -45,7 +46,7 @@ export function useAuth(): AuthState {
|
||||
}
|
||||
|
||||
const sessionRes = await fetch("/api/auth/session", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
|
||||
if (sessionRes.ok) {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { PYTHON_SIDECAR_TOOLS } from "@stirling-image/shared";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
interface ProcessResult {
|
||||
jobId: string;
|
||||
downloadUrl: string;
|
||||
@@ -222,10 +219,9 @@ export function useToolProcessor(toolId: string) {
|
||||
};
|
||||
|
||||
xhr.open("POST", `/api/v1/tools/${toolId}`);
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
formatHeaders().forEach((value, key) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
xhr.send(formData);
|
||||
},
|
||||
[toolId, isAiTool, setProcessing, setError, setProcessedUrl, setSizes, setJobId],
|
||||
@@ -292,10 +288,9 @@ export function useToolProcessor(toolId: string) {
|
||||
formData.append("clientJobId", clientJobId);
|
||||
|
||||
try {
|
||||
const token = getToken();
|
||||
const response = await fetch(`/api/v1/tools/${toolId}/batch`, {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
||||
+29
-23
@@ -1,5 +1,26 @@
|
||||
const API_BASE = "/api";
|
||||
|
||||
// ── Auth Headers ───────────────────────────────────────────────
|
||||
|
||||
function getToken(): string {
|
||||
try {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Skip Authorization header when no token exists.
|
||||
// An empty Bearer token breaks forward-auth proxies (e.g. Authelia).
|
||||
export function formatHeaders(init?: HeadersInit): Headers {
|
||||
const headers = new Headers(init);
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function throwWithMessage(res: Response): Promise<never> {
|
||||
let msg = `API error: ${res.status}`;
|
||||
try {
|
||||
@@ -14,7 +35,7 @@ async function throwWithMessage(res: Response): Promise<never> {
|
||||
|
||||
export async function apiGet<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
if (!res.ok) await throwWithMessage(res);
|
||||
return res.json();
|
||||
@@ -23,10 +44,7 @@ export async function apiGet<T>(path: string): Promise<T> {
|
||||
export async function apiPost<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
},
|
||||
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) await throwWithMessage(res);
|
||||
@@ -36,10 +54,7 @@ export async function apiPost<T>(path: string, body?: unknown): Promise<T> {
|
||||
export async function apiPut<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
},
|
||||
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) await throwWithMessage(res);
|
||||
@@ -49,18 +64,12 @@ export async function apiPut<T>(path: string, body?: unknown): Promise<T> {
|
||||
export async function apiDelete<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
},
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
if (!res.ok) await throwWithMessage(res);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem("stirling-token", token);
|
||||
}
|
||||
@@ -79,7 +88,7 @@ export async function apiUpload(files: File[]): Promise<{
|
||||
for (const f of files) formData.append("files", f);
|
||||
const res = await fetch("/api/v1/upload", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
||||
@@ -141,7 +150,7 @@ export async function apiUploadUserFiles(
|
||||
for (const f of files) formData.append("files", f);
|
||||
const res = await fetch("/api/v1/files/upload", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
||||
@@ -151,10 +160,7 @@ export async function apiUploadUserFiles(
|
||||
export async function apiDeleteUserFiles(ids: string[]): Promise<{ deleted: number }> {
|
||||
const res = await fetch("/api/v1/files", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
},
|
||||
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
|
||||
@@ -171,7 +177,7 @@ export function getFileDownloadUrl(id: string): string {
|
||||
|
||||
export async function apiDownloadBlob(jobId: string, filename: string): Promise<Blob> {
|
||||
const res = await fetch(getDownloadUrl(jobId, filename), {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
||||
return res.blob();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Play, Trash2, Workflow } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { PipelineBuilder, type PipelineStep } from "@/components/tools/pipeline-builder";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { generateId } from "@/lib/utils";
|
||||
|
||||
interface SavedPipeline {
|
||||
@@ -11,11 +12,6 @@ interface SavedPipeline {
|
||||
steps: Array<{ toolId: string; settings: Record<string, unknown> }>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function AutomatePage() {
|
||||
const [steps, setSteps] = useState<PipelineStep[]>([]);
|
||||
const [savedPipelines, setSavedPipelines] = useState<SavedPipeline[]>([]);
|
||||
@@ -33,7 +29,7 @@ export function AutomatePage() {
|
||||
const loadPipelines = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/pipeline/list", {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
@@ -55,10 +51,7 @@ export function AutomatePage() {
|
||||
try {
|
||||
const res = await fetch("/api/v1/pipeline/save", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
},
|
||||
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description: description || undefined,
|
||||
@@ -86,7 +79,7 @@ export function AutomatePage() {
|
||||
try {
|
||||
await fetch(`/api/v1/pipeline/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
await loadPipelines();
|
||||
} catch {
|
||||
@@ -117,7 +110,7 @@ export function AutomatePage() {
|
||||
|
||||
const res = await fetch("/api/v1/pipeline/execute", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type FormEvent, useRef, useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Trigger the browser's "Save Password" prompt by submitting a real form
|
||||
@@ -85,13 +86,9 @@ export function ChangePasswordPage() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem("stirling-token");
|
||||
const res = await fetch("/api/auth/change-password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
|
||||
|
||||
@@ -525,18 +525,18 @@ describe("API lib", () => {
|
||||
|
||||
const result = await apiGet<{ data: string }>("/v1/health");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/v1/health", {
|
||||
headers: { Authorization: "Bearer tok-123" },
|
||||
});
|
||||
const [url, opts] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/v1/health");
|
||||
expect(opts.headers.get("Authorization")).toBe("Bearer tok-123");
|
||||
expect(result).toEqual({ data: "ok" });
|
||||
});
|
||||
|
||||
it("sends empty Bearer when no token is set", async () => {
|
||||
it("omits Authorization header when no token is set", async () => {
|
||||
fetchMock.mockReturnValueOnce(okJson({}));
|
||||
await apiGet("/v1/anything");
|
||||
|
||||
const callArgs = fetchMock.mock.calls[0];
|
||||
expect(callArgs[1].headers.Authorization).toBe("Bearer ");
|
||||
expect(callArgs[1].headers.get("Authorization")).toBeNull();
|
||||
});
|
||||
|
||||
it("throws on non-ok response (e.g., 401)", async () => {
|
||||
@@ -567,8 +567,8 @@ describe("API lib", () => {
|
||||
const [url, opts] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/v1/items");
|
||||
expect(opts.method).toBe("POST");
|
||||
expect(opts.headers["Content-Type"]).toBe("application/json");
|
||||
expect(opts.headers.Authorization).toBe("Bearer post-tok");
|
||||
expect(opts.headers.get("Content-Type")).toBe("application/json");
|
||||
expect(opts.headers.get("Authorization")).toBe("Bearer post-tok");
|
||||
expect(opts.body).toBe(JSON.stringify({ name: "test" }));
|
||||
expect(result).toEqual({ id: 1 });
|
||||
});
|
||||
@@ -599,8 +599,8 @@ describe("API lib", () => {
|
||||
const [url, opts] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/v1/items/1");
|
||||
expect(opts.method).toBe("PUT");
|
||||
expect(opts.headers["Content-Type"]).toBe("application/json");
|
||||
expect(opts.headers.Authorization).toBe("Bearer put-tok");
|
||||
expect(opts.headers.get("Content-Type")).toBe("application/json");
|
||||
expect(opts.headers.get("Authorization")).toBe("Bearer put-tok");
|
||||
expect(opts.body).toBe(JSON.stringify({ name: "updated" }));
|
||||
expect(result).toEqual({ updated: true });
|
||||
});
|
||||
@@ -623,7 +623,7 @@ describe("API lib", () => {
|
||||
const [url, opts] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/v1/items/1");
|
||||
expect(opts.method).toBe("DELETE");
|
||||
expect(opts.headers.Authorization).toBe("Bearer del-tok");
|
||||
expect(opts.headers.get("Authorization")).toBe("Bearer del-tok");
|
||||
expect(opts.body).toBeUndefined();
|
||||
expect(result).toEqual({ deleted: true });
|
||||
});
|
||||
@@ -651,7 +651,7 @@ describe("API lib", () => {
|
||||
const [url, opts] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/v1/upload");
|
||||
expect(opts.method).toBe("POST");
|
||||
expect(opts.headers.Authorization).toBe("Bearer up-tok");
|
||||
expect(opts.headers.get("Authorization")).toBe("Bearer up-tok");
|
||||
// Body should be FormData
|
||||
expect(opts.body).toBeInstanceOf(FormData);
|
||||
const fd = opts.body as FormData;
|
||||
@@ -673,7 +673,7 @@ describe("API lib", () => {
|
||||
await apiUpload([makeFile("x.png")]);
|
||||
|
||||
const headers = fetchMock.mock.calls[0][1].headers;
|
||||
expect(headers["Content-Type"]).toBeUndefined();
|
||||
expect(headers.get("Content-Type")).toBeNull();
|
||||
});
|
||||
|
||||
it("throws on non-ok response with status in message", async () => {
|
||||
@@ -708,7 +708,7 @@ describe("API lib", () => {
|
||||
|
||||
const [url, opts] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/v1/download/job-1/result.png");
|
||||
expect(opts.headers.Authorization).toBe("Bearer dl-tok");
|
||||
expect(opts.headers.get("Authorization")).toBe("Bearer dl-tok");
|
||||
expect(result).toBe(blob);
|
||||
});
|
||||
|
||||
@@ -748,20 +748,20 @@ describe("API lib", () => {
|
||||
setToken("first-token");
|
||||
fetchMock.mockReturnValueOnce(okJson({}));
|
||||
await apiGet("/v1/a");
|
||||
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe("Bearer first-token");
|
||||
expect(fetchMock.mock.calls[0][1].headers.get("Authorization")).toBe("Bearer first-token");
|
||||
|
||||
setToken("second-token");
|
||||
fetchMock.mockReturnValueOnce(okJson({}));
|
||||
await apiGet("/v1/b");
|
||||
expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe("Bearer second-token");
|
||||
expect(fetchMock.mock.calls[1][1].headers.get("Authorization")).toBe("Bearer second-token");
|
||||
});
|
||||
|
||||
it("uses empty Bearer immediately after clearToken", async () => {
|
||||
it("omits Authorization header after clearToken", async () => {
|
||||
setToken("about-to-die");
|
||||
clearToken();
|
||||
fetchMock.mockReturnValueOnce(okJson({}));
|
||||
await apiGet("/v1/c");
|
||||
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe("Bearer ");
|
||||
expect(fetchMock.mock.calls[0][1].headers.get("Authorization")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user