File size: 4,388 Bytes
783fcb6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37211b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
783fcb6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37211b8
783fcb6
 
 
 
37211b8
783fcb6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
const API_UNAUTHORIZED_EVENT = "masters_toolkit_api_unauthorized";
const FETCH_PATCH_FLAG = "__mastersToolkitAuthFetchPatched";

export type ApiUnauthorizedDetail = {
  status: number;
  url: string;
  message: string;
  requestId?: string;
};

let lastUnauthorizedKey = "";
let lastUnauthorizedAt = 0;

function isProtectedPath(pathname: string): boolean {
  return pathname.startsWith("/api/") || pathname.startsWith("/masters_files/") || pathname.startsWith("/pots_files/");
}

function resolveRequestUrl(input: RequestInfo | URL): URL | null {
  try {
    if (typeof input === "string") return new URL(input, window.location.origin);
    if (input instanceof URL) return new URL(input.toString(), window.location.origin);
    if (typeof Request !== "undefined" && input instanceof Request) {
      return new URL(input.url, window.location.origin);
    }
    return null;
  } catch {
    return null;
  }
}

function requestHasAuthorization(input: RequestInfo | URL, init?: RequestInit): boolean {
  try {
    const initHeaders = new Headers(init?.headers || {});
    if (initHeaders.has("Authorization")) return true;
  } catch {
    // ignore
  }

  try {
    if (typeof Request !== "undefined" && input instanceof Request) {
      return input.headers.has("Authorization");
    }
  } catch {
    // ignore
  }
  return false;
}

function emitUnauthorized(detail: ApiUnauthorizedDetail): void {
  if (typeof window === "undefined") return;
  const key = `${detail.status}|${detail.url}|${detail.message}`.slice(0, 512);
  const now = Date.now();
  if (key === lastUnauthorizedKey && now - lastUnauthorizedAt < 1200) return;
  lastUnauthorizedKey = key;
  lastUnauthorizedAt = now;
  window.dispatchEvent(new CustomEvent<ApiUnauthorizedDetail>(API_UNAUTHORIZED_EVENT, { detail }));
}

async function parseErrorMessage(response: Response): Promise<string> {
  try {
    const clone = response.clone();
    const contentType = String(clone.headers.get("content-type") || "").toLowerCase();
    if (contentType.includes("application/json")) {
      const payload = (await clone.json()) as Record<string, unknown>;
      const detail = payload?.detail;
      if (typeof detail === "string" && detail.trim()) return detail.trim();
      const err = payload?.error;
      if (typeof err === "string" && err.trim()) return err.trim();
    }
    const text = (await clone.text()).trim();
    if (text) return text;
  } catch {
    // ignore parse failures
  }
  return "";
}

export function installApiAuthResponseMonitor(): void {
  if (typeof window === "undefined") return;
  const win = window as Window & { [FETCH_PATCH_FLAG]?: boolean };
  if (win[FETCH_PATCH_FLAG]) return;
  win[FETCH_PATCH_FLAG] = true;

  const originalFetch = window.fetch.bind(window);
  window.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
    const response = await originalFetch(input, init);
    const url = resolveRequestUrl(input);
    if (!url || !isProtectedPath(url.pathname)) return response;
    const hadAuthorization = requestHasAuthorization(input, init);

    if (response.status === 401 || response.status === 403) {
      const authErrorHeader = String(response.headers.get("x-masters-auth-error") || "").trim().toLowerCase();
      const isTaggedAuthError = authErrorHeader === "1" || authErrorHeader === "true";
      if (response.status === 401 && !hadAuthorization) return response;
      if (response.status === 403 && !isTaggedAuthError) return response;
      void (async () => {
        const detailMessage = await parseErrorMessage(response);
        emitUnauthorized({
          status: response.status,
          url: `${url.pathname}${url.search}`,
          message: detailMessage || `API request failed (${response.status}).`,
          requestId: response.headers.get("x-request-id") || undefined,
        });
      })();
    }
    return response;
  }) as typeof window.fetch;
}

export function onApiUnauthorized(handler: (detail: ApiUnauthorizedDetail) => void): () => void {
  if (typeof window === "undefined") return () => {};
  const wrapped = (event: Event) => {
    const custom = event as CustomEvent<ApiUnauthorizedDetail>;
    handler(custom.detail);
  };
  window.addEventListener(API_UNAUTHORIZED_EVENT, wrapped as EventListener);
  return () => window.removeEventListener(API_UNAUTHORIZED_EVENT, wrapped as EventListener);
}