chore: standardize security and quality foundations
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { apiUrl, requestJson } from "./api-client";
|
||||
|
||||
describe("api client", () => {
|
||||
it("normalizes relative API paths", () => {
|
||||
expect(apiUrl("health")).toBe("/api/health");
|
||||
expect(apiUrl("/health")).toBe("/api/health");
|
||||
});
|
||||
|
||||
it("returns typed JSON from successful responses", async () => {
|
||||
const transport = async () => new Response(JSON.stringify({ status: "ok" }), { status: 200 });
|
||||
const result = await requestJson<{ status: string }>("/health", undefined, transport);
|
||||
expect(result).toEqual({ status: "ok" });
|
||||
});
|
||||
|
||||
it("uses the API error detail when a request fails", async () => {
|
||||
const transport = async () => new Response(JSON.stringify({ detail: "Not available" }), { status: 409 });
|
||||
await expect(requestJson("/requests/1", undefined, transport)).rejects.toEqual(
|
||||
expect.objectContaining({ status: 409, message: "Not available" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { authFetchOrThrow, getApiBase } from "./auth";
|
||||
|
||||
export type ApiTransport = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiClientError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = (payload: unknown, fallback: string) => {
|
||||
if (!payload || typeof payload !== "object") return fallback;
|
||||
const record = payload as Record<string, unknown>;
|
||||
for (const key of ["detail", "error", "message"]) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export const apiUrl = (path: string) => {
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${getApiBase()}${normalizedPath}`;
|
||||
};
|
||||
|
||||
export async function requestJson<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
transport: ApiTransport = authFetchOrThrow,
|
||||
): Promise<T> {
|
||||
const response = await transport(apiUrl(path), init);
|
||||
if (response.status === 204) return undefined as T;
|
||||
|
||||
const text = await response.text();
|
||||
let payload: unknown = null;
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiClientError(response.status, errorMessage(payload, text || `Request failed: ${response.status}`));
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
+55
-55
@@ -1,100 +1,100 @@
|
||||
const AUTH_STATE_COOKIE = 'magent_logged_in'
|
||||
const AUTH_STATE_COOKIE = "magent_logged_in";
|
||||
|
||||
export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? '/api'
|
||||
export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? "/api";
|
||||
|
||||
const setCookie = (name: string, value: string, maxAgeSeconds: number) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`
|
||||
}
|
||||
if (typeof document === "undefined") return;
|
||||
document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`;
|
||||
};
|
||||
|
||||
const clearCookie = (name: string) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`
|
||||
}
|
||||
if (typeof document === "undefined") return;
|
||||
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`;
|
||||
};
|
||||
|
||||
export const getToken = () => {
|
||||
if (typeof document === 'undefined') return null
|
||||
const cookies = document.cookie.split(';').map((entry) => entry.trim())
|
||||
const marker = cookies.find((entry) => entry.startsWith(`${AUTH_STATE_COOKIE}=`))
|
||||
if (!marker) return null
|
||||
const [, value] = marker.split('=', 2)
|
||||
return value || null
|
||||
}
|
||||
if (typeof document === "undefined") return null;
|
||||
const cookies = document.cookie.split(";").map((entry) => entry.trim());
|
||||
const marker = cookies.find((entry) => entry.startsWith(`${AUTH_STATE_COOKIE}=`));
|
||||
if (!marker) return null;
|
||||
const [, value] = marker.split("=", 2);
|
||||
return value || null;
|
||||
};
|
||||
|
||||
export const setToken = (_token: string) => {
|
||||
setCookie(AUTH_STATE_COOKIE, '1', 60 * 60 * 12)
|
||||
}
|
||||
setCookie(AUTH_STATE_COOKIE, "1", 60 * 60 * 12);
|
||||
};
|
||||
|
||||
export const clearToken = () => {
|
||||
clearCookie(AUTH_STATE_COOKIE)
|
||||
if (typeof window === 'undefined') return
|
||||
const baseUrl = getApiBase()
|
||||
clearCookie(AUTH_STATE_COOKIE);
|
||||
if (typeof window === "undefined") return;
|
||||
const baseUrl = getApiBase();
|
||||
void fetch(`${baseUrl}/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
keepalive: true,
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
export const logout = async () => {
|
||||
const baseUrl = getApiBase()
|
||||
clearCookie(AUTH_STATE_COOKIE)
|
||||
const baseUrl = getApiBase();
|
||||
clearCookie(AUTH_STATE_COOKIE);
|
||||
await fetch(`${baseUrl}/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
};
|
||||
|
||||
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers || {})
|
||||
return fetch(input, { ...init, headers, credentials: 'include' })
|
||||
}
|
||||
const headers = new Headers(init?.headers || {});
|
||||
return fetch(input, { ...init, headers, credentials: "include" });
|
||||
};
|
||||
|
||||
export const getEventStreamToken = async () => {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/auth/stream-token`)
|
||||
const baseUrl = getApiBase();
|
||||
const response = await authFetch(`${baseUrl}/auth/stream-token`);
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(text || `Stream token request failed: ${response.status}`)
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Stream token request failed: ${response.status}`);
|
||||
}
|
||||
const data = await response.json()
|
||||
const token = typeof data?.stream_token === 'string' ? data.stream_token : ''
|
||||
const data = await response.json();
|
||||
const token = typeof data?.stream_token === "string" ? data.stream_token : "";
|
||||
if (!token) {
|
||||
throw new Error('Stream token not returned')
|
||||
throw new Error("Stream token not returned");
|
||||
}
|
||||
return token
|
||||
}
|
||||
return token;
|
||||
};
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor() {
|
||||
super('Unauthorized')
|
||||
this.name = 'UnauthorizedError'
|
||||
super("Unauthorized");
|
||||
this.name = "UnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends Error {
|
||||
constructor() {
|
||||
super('Forbidden')
|
||||
this.name = 'ForbiddenError'
|
||||
super("Forbidden");
|
||||
this.name = "ForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const response = await authFetch(input, init)
|
||||
const response = await authFetch(input, init);
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
throw new UnauthorizedError()
|
||||
clearToken();
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new ForbiddenError()
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
return response
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
export const readResponseText = async (response: Response) => {
|
||||
try {
|
||||
return (await response.text()).trim()
|
||||
return (await response.text()).trim();
|
||||
} catch {
|
||||
return ''
|
||||
return "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,24 +1,43 @@
|
||||
export const FEATURES = [
|
||||
{ key: 'stats', label: 'My Stats', description: 'View personal viewing history, reports and request report emails.' },
|
||||
{ key: 'requests', label: 'My Requests', description: 'View existing requests, their progress and request actions.' },
|
||||
{ key: 'new_requests', label: 'New Requests', description: 'Search for movies and TV shows and submit new requests.' },
|
||||
{ key: 'issues', label: 'Issues', description: 'Report problems, follow up on issues and use available repair tools.' },
|
||||
{ key: 'invites', label: 'Invites', description: 'Create and manage invitations within the existing invite limits.' },
|
||||
{ key: 'ignore_profile_limits', label: 'Ignore profile limits', description: 'Allow explicit manual downloads outside quality, size, language or custom-format limits. Automatic searches keep the assigned profile.' },
|
||||
] as const
|
||||
export type Feature = typeof FEATURES[number]['key']
|
||||
export type FeatureAccess = Record<Feature, boolean>
|
||||
{ key: "stats", label: "My Stats", description: "View personal viewing history, reports and request report emails." },
|
||||
{ key: "requests", label: "My Requests", description: "View existing requests, their progress and request actions." },
|
||||
{
|
||||
key: "new_requests",
|
||||
label: "New Requests",
|
||||
description: "Search for movies and TV shows and submit new requests.",
|
||||
},
|
||||
{
|
||||
key: "issues",
|
||||
label: "Issues",
|
||||
description: "Report problems, follow up on issues and use available repair tools.",
|
||||
},
|
||||
{ key: "invites", label: "Invites", description: "Create and manage invitations within the existing invite limits." },
|
||||
{
|
||||
key: "ignore_profile_limits",
|
||||
label: "Ignore profile limits",
|
||||
description:
|
||||
"Allow explicit manual downloads outside quality, size, language or custom-format limits. Automatic searches keep the assigned profile.",
|
||||
},
|
||||
] as const;
|
||||
export type Feature = (typeof FEATURES)[number]["key"];
|
||||
export type FeatureAccess = Record<Feature, boolean>;
|
||||
export function featureForPath(path: string): Feature | undefined {
|
||||
if (path === '/insights' || path.startsWith('/insights/')) return 'stats'
|
||||
if (path === '/' || path.startsWith('/requests/')) return 'requests'
|
||||
if (path === '/new-requests') return 'new_requests'
|
||||
if (path.startsWith('/issues/confirm/') || path.startsWith('/portal/issues')) return 'issues'
|
||||
if (path.startsWith('/profile/invites')) return 'invites'
|
||||
if (path === '/portal/requests') return 'requests'
|
||||
if (path === "/insights" || path.startsWith("/insights/")) return "stats";
|
||||
if (path === "/" || path.startsWith("/requests/")) return "requests";
|
||||
if (path === "/new-requests") return "new_requests";
|
||||
if (path.startsWith("/issues/confirm/") || path.startsWith("/portal/issues")) return "issues";
|
||||
if (path.startsWith("/profile/invites")) return "invites";
|
||||
if (path === "/portal/requests") return "requests";
|
||||
}
|
||||
export function canAccess(user: { role?: string; features?: Partial<FeatureAccess>; invite_management_enabled?: boolean } | null, feature?: Feature) {
|
||||
if (!feature) return true
|
||||
if (!user) return false
|
||||
if (user.role === 'admin') return true
|
||||
return user.features?.[feature] ?? (feature === 'invites' ? Boolean(user.invite_management_enabled) : feature !== 'ignore_profile_limits')
|
||||
export function canAccess(
|
||||
user: { role?: string; features?: Partial<FeatureAccess>; invite_management_enabled?: boolean } | null,
|
||||
feature?: Feature,
|
||||
) {
|
||||
if (!feature) return true;
|
||||
if (!user) return false;
|
||||
if (user.role === "admin") return true;
|
||||
return (
|
||||
user.features?.[feature] ??
|
||||
(feature === "invites" ? Boolean(user.invite_management_enabled) : feature !== "ignore_profile_limits")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { normalizeRecentResults, normalizeSearchResults } from "./request-results";
|
||||
|
||||
describe("request result normalization", () => {
|
||||
it("replaces placeholder request titles", () => {
|
||||
expect(normalizeRecentResults([{ id: 42, title: "Request 42", year: 2024 }])).toEqual([
|
||||
expect.objectContaining({ id: 42, title: "Request #42", year: 2024 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops malformed search results", () => {
|
||||
expect(normalizeSearchResults([null, { title: "" }, { title: "Drive", requestId: 3991 }])).toEqual([
|
||||
expect.objectContaining({ title: "Drive", requestId: 3991 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
export interface RecentRequest {
|
||||
id: number;
|
||||
title: string;
|
||||
year?: number;
|
||||
type?: string;
|
||||
statusLabel?: string;
|
||||
artwork?: { poster_url?: string; backdrop_url?: string };
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
||||
export interface RequestSearchResult {
|
||||
title: string;
|
||||
year?: number;
|
||||
type?: string;
|
||||
requestId?: number;
|
||||
statusLabel?: string;
|
||||
requestedBy?: string | null;
|
||||
accessible?: boolean;
|
||||
}
|
||||
|
||||
const recordValue = (value: unknown): Record<string, unknown> | null =>
|
||||
value !== null && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
|
||||
const optionalString = (value: unknown) => (typeof value === "string" ? value : undefined);
|
||||
const optionalNumber = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : undefined);
|
||||
|
||||
export const normalizeRecentResults = (items: unknown): RecentRequest[] => {
|
||||
if (!Array.isArray(items)) return [];
|
||||
return items.flatMap((value) => {
|
||||
const item = recordValue(value);
|
||||
const id = optionalNumber(item?.id);
|
||||
if (!item || id === undefined) return [];
|
||||
const rawTitle = optionalString(item.title);
|
||||
const placeholder = rawTitle?.trim().toLowerCase() === `request ${id}`;
|
||||
const rawArtwork = recordValue(item.artwork);
|
||||
const artwork = rawArtwork
|
||||
? {
|
||||
poster_url: optionalString(rawArtwork.poster_url),
|
||||
backdrop_url: optionalString(rawArtwork.backdrop_url),
|
||||
}
|
||||
: undefined;
|
||||
return [
|
||||
{
|
||||
id,
|
||||
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
|
||||
year: optionalNumber(item.year),
|
||||
type: optionalString(item.type),
|
||||
statusLabel: optionalString(item.statusLabel),
|
||||
artwork,
|
||||
createdAt: item.createdAt === null ? null : optionalString(item.createdAt),
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
export const normalizeSearchResults = (items: unknown): RequestSearchResult[] => {
|
||||
if (!Array.isArray(items)) return [];
|
||||
return items.flatMap((value) => {
|
||||
const item = recordValue(value);
|
||||
const title = optionalString(item?.title);
|
||||
if (!item || !title) return [];
|
||||
return [
|
||||
{
|
||||
title,
|
||||
year: optionalNumber(item.year),
|
||||
type: optionalString(item.type),
|
||||
requestId: optionalNumber(item.requestId),
|
||||
statusLabel: optionalString(item.statusLabel),
|
||||
requestedBy: item.requestedBy === null ? null : optionalString(item.requestedBy),
|
||||
accessible: Boolean(item.accessible),
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
@@ -1,15 +1,15 @@
|
||||
let locks = 0
|
||||
let previous = ''
|
||||
let locks = 0;
|
||||
let previous = "";
|
||||
|
||||
export function lockBodyScroll() {
|
||||
if (locks++ === 0) {
|
||||
previous = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
previous = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
let released = false
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return
|
||||
released = true
|
||||
if (--locks === 0) document.body.style.overflow = previous
|
||||
}
|
||||
if (released) return;
|
||||
released = true;
|
||||
if (--locks === 0) document.body.style.overflow = previous;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const USER_VIEW_STORAGE_KEY = 'magent_user_view_preview'
|
||||
const USER_VIEW_EVENT = 'magent:user-view-change'
|
||||
const USER_VIEW_STORAGE_KEY = "magent_user_view_preview";
|
||||
const USER_VIEW_EVENT = "magent:user-view-change";
|
||||
|
||||
const readUserViewPreview = () => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === '1'
|
||||
}
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === "1";
|
||||
};
|
||||
|
||||
const applyDocumentMode = (enabled: boolean) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.documentElement.dataset.userView = enabled ? 'true' : 'false'
|
||||
}
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.dataset.userView = enabled ? "true" : "false";
|
||||
};
|
||||
|
||||
export const setUserViewPreview = (enabled: boolean) => {
|
||||
if (typeof window === 'undefined') return
|
||||
if (typeof window === "undefined") return;
|
||||
if (enabled) {
|
||||
window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, '1')
|
||||
window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, "1");
|
||||
} else {
|
||||
window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY)
|
||||
window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY);
|
||||
}
|
||||
applyDocumentMode(enabled)
|
||||
window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } }))
|
||||
}
|
||||
applyDocumentMode(enabled);
|
||||
window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } }));
|
||||
};
|
||||
|
||||
export const useUserViewPreview = () => {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
const nextValue = readUserViewPreview()
|
||||
applyDocumentMode(nextValue)
|
||||
setEnabled(nextValue)
|
||||
}
|
||||
sync()
|
||||
window.addEventListener(USER_VIEW_EVENT, sync)
|
||||
window.addEventListener('storage', sync)
|
||||
const nextValue = readUserViewPreview();
|
||||
applyDocumentMode(nextValue);
|
||||
setEnabled(nextValue);
|
||||
};
|
||||
sync();
|
||||
window.addEventListener(USER_VIEW_EVENT, sync);
|
||||
window.addEventListener("storage", sync);
|
||||
return () => {
|
||||
window.removeEventListener(USER_VIEW_EVENT, sync)
|
||||
window.removeEventListener('storage', sync)
|
||||
}
|
||||
}, [])
|
||||
window.removeEventListener(USER_VIEW_EVENT, sync);
|
||||
window.removeEventListener("storage", sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return enabled
|
||||
}
|
||||
return enabled;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user