fix(pl24): tr hesabı kapalıyken de köprüsü (PL24_TR_DISABLED) + login devre kesici
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
- tr-903645 PL24 tarafında pasif (07-24, "The account is not active"): flag açıkken tüm tr auth/proxy/egress şeffaf biçimde de-708171'e maplenir (PSA dahil — legacy authorizeService yolu tr'ye hardcode'du) - PSA fetch'lerine dispatcher eklendi: de oturumu DE proxy'den çıkar - resolveAccount flag altında herkese de döner (fetch/cache/etiket tutarlı) - login devre kesici: 3 ardışık hata → 5 dk soğuma (haftalardır süren ~6 boş login/dk fırtınasını bitirir) - prefetch: flag açıkken pl24 backfill üretimi durur + kuyruktaki pl24 main-lane işleri 15 dk defer'la park edilir (fast/user lane akmaya devam eder) — hayatta kalan tek hesabı arka plan yükü yakmasın - compose: PL24_TR_DISABLED env injection (api+worker) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
127
apps/api/src/integrations/pl24/pl24-auth.service.spec.ts
Normal file
127
apps/api/src/integrations/pl24/pl24-auth.service.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
|
||||
// PL24_TR_DISABLED köprüsü + login devre kesici: ağ yok, login yolları global
|
||||
// fetch stub'ıyla gözlenir; private durum repo genelindeki `as unknown as` kalıbıyla okunur.
|
||||
|
||||
const makeService = (cfgOverrides: Record<string, string> = {}) => {
|
||||
const cfg: Record<string, string> = {
|
||||
"pl24.companyCode": "tr-903645",
|
||||
"pl24.username": "admin",
|
||||
"pl24.password": "pw-tr",
|
||||
"pl24.companyCode2": "de-708171",
|
||||
"pl24.username2": "admin",
|
||||
"pl24.password2": "pw-de",
|
||||
"pl24.proxyDe": "http://u:p@127.0.0.1:9",
|
||||
...cfgOverrides,
|
||||
};
|
||||
const configService = { get: (k: string, d?: unknown) => cfg[k] ?? d } as never;
|
||||
const redis = { getJson: async () => null, setJson: async () => {} } as never;
|
||||
return new PL24AuthService(configService, redis);
|
||||
};
|
||||
|
||||
type Exposed = {
|
||||
effectiveAccount(account: "tr" | "de"): "tr" | "de";
|
||||
login(forceNew?: boolean): Promise<unknown>;
|
||||
login2(forceNew?: boolean): Promise<unknown>;
|
||||
tokenData2: unknown;
|
||||
};
|
||||
|
||||
const savedFlag = process.env.PL24_TR_DISABLED;
|
||||
|
||||
beforeEach(() => {
|
||||
// "" ≠ "true" → bayrak kapalı; delete yerine atama (biome noDelete).
|
||||
process.env.PL24_TR_DISABLED = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PL24_TR_DISABLED = savedFlag ?? "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("PL24AuthService — PL24_TR_DISABLED tr→de köprüsü", () => {
|
||||
it("bayrak kapalıyken hesaplar olduğu gibi kalır", () => {
|
||||
const p = makeService() as unknown as Exposed;
|
||||
expect(p.effectiveAccount("tr")).toBe("tr");
|
||||
expect(p.effectiveAccount("de")).toBe("de");
|
||||
});
|
||||
|
||||
it("bayrak açıkken tr → de'ye maplenir, de değişmez", () => {
|
||||
process.env.PL24_TR_DISABLED = "true";
|
||||
const p = makeService() as unknown as Exposed;
|
||||
expect(p.effectiveAccount("tr")).toBe("de");
|
||||
expect(p.effectiveAccount("de")).toBe("de");
|
||||
});
|
||||
|
||||
it("de hesabı tanımlı değilse mapleme yapılmaz (duvara yönlendirme yok)", () => {
|
||||
process.env.PL24_TR_DISABLED = "true";
|
||||
const p = makeService({ "pl24.companyCode2": "" }) as unknown as Exposed;
|
||||
expect(p.effectiveAccount("tr")).toBe("tr");
|
||||
});
|
||||
|
||||
it("bayrak açıkken tr login'i ağa çıkmadan reddedilir", async () => {
|
||||
process.env.PL24_TR_DISABLED = "true";
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const p = makeService() as unknown as Exposed;
|
||||
await expect(p.login()).rejects.toThrow(/PL24_TR_DISABLED/);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("legacy clearTokens (tr) bayrak altında de token'larını temizler", () => {
|
||||
process.env.PL24_TR_DISABLED = "true";
|
||||
const svc = makeService();
|
||||
const p = svc as unknown as Exposed;
|
||||
p.tokenData2 = { accessToken: "x", sessionCookie: "c", expiresAt: new Date(), services: [] };
|
||||
svc.clearTokens();
|
||||
expect(p.tokenData2).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PL24AuthService — login devre kesici", () => {
|
||||
it("3 ardışık başarısız login'den sonra açılır ve yeni ağ denemesini keser", async () => {
|
||||
const fetchSpy = vi.fn().mockRejectedValue(new Error("network down"));
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const p = makeService() as unknown as Exposed;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await expect(p.login2()).rejects.toThrow(/giris hatasi/);
|
||||
}
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
|
||||
// 4. deneme: kesici açık — fetch'e hiç gidilmez.
|
||||
await expect(p.login2()).rejects.toThrow(/breaker open/);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("başarılı login kesici sayacını sıfırlar", async () => {
|
||||
let failFirst = 2;
|
||||
const okResponse = {
|
||||
ok: true,
|
||||
headers: { get: () => null },
|
||||
json: async () => ({
|
||||
token: {
|
||||
// exp uzak gelecekte olan imzasız-görünümlü JWT payload'ı
|
||||
access_token: `h.${Buffer.from(
|
||||
JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, services: ["x"] }),
|
||||
).toString("base64url")}.s`,
|
||||
},
|
||||
}),
|
||||
};
|
||||
const fetchSpy = vi.fn().mockImplementation(async () => {
|
||||
if (failFirst > 0) {
|
||||
failFirst -= 1;
|
||||
throw new Error("network down");
|
||||
}
|
||||
return okResponse;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const p = makeService() as unknown as Exposed;
|
||||
|
||||
await expect(p.login2()).rejects.toThrow();
|
||||
await expect(p.login2()).rejects.toThrow();
|
||||
await expect(p.login2(true)).resolves.toBeTruthy(); // 3. deneme başarılı → sayaç sıfır
|
||||
await expect(p.login2(true)).resolves.toBeTruthy(); // kesici açılmadı, akış normal
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,18 @@ export class PL24AuthService implements OnModuleInit {
|
||||
private serviceTokens2 = new Map<string, { token: string; expiresAt: Date }>();
|
||||
private proxyAgent: any = null; // undici.ProxyAgent, lazy-init
|
||||
|
||||
// ── tr-outage bridge + login circuit breaker ───────────────────────────────
|
||||
// PL24_TR_DISABLED=true: the tr account is inactive on PL24's side ("The
|
||||
// account is not active", 2026-07-24) — every tr request is transparently
|
||||
// served by the de account until the flag is removed. The breaker stops the
|
||||
// pre-outage failure mode where a dead account was retried ~6x/min for weeks.
|
||||
private static readonly BREAKER_THRESHOLD = 3;
|
||||
private static readonly BREAKER_COOLDOWN_MS = 5 * 60_000;
|
||||
private readonly loginBreaker = {
|
||||
tr: { fails: 0, openUntil: 0 },
|
||||
de: { fails: 0, openUntil: 0 },
|
||||
};
|
||||
|
||||
// ── Config ─────────────────────────────────────────────────────────────────
|
||||
private readonly baseUrl: string;
|
||||
private readonly companyCode: string;
|
||||
@@ -69,6 +81,11 @@ export class PL24AuthService implements OnModuleInit {
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (this.trDisabled()) {
|
||||
this.logger.warn(
|
||||
"PL24_TR_DISABLED=true — routing ALL PL24 traffic (auth + proxy egress) to the de account",
|
||||
);
|
||||
}
|
||||
// In-memory auth tokens are cleared on every (re)deploy, so the first VIN
|
||||
// decode afterwards otherwise pays the ~10s PL24 login + service-authorize
|
||||
// handshake on the request path. Warm it in the background so users never do.
|
||||
@@ -127,8 +144,49 @@ export class PL24AuthService implements OnModuleInit {
|
||||
// ── Public: per-account API ──────────────────────────────────────────────
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
private trDisabled(): boolean {
|
||||
return process.env.PL24_TR_DISABLED === "true";
|
||||
}
|
||||
|
||||
/** Map tr → de while PL24_TR_DISABLED is set (and de is configured). */
|
||||
private effectiveAccount(account: "tr" | "de"): "tr" | "de" {
|
||||
return account === "tr" && this.trDisabled() && this.companyCode2 ? "de" : account;
|
||||
}
|
||||
|
||||
private breakerGate(account: "tr" | "de"): void {
|
||||
const b = this.loginBreaker[account];
|
||||
if (Date.now() < b.openUntil) {
|
||||
throw new UnauthorizedException(
|
||||
`PL24 ${account} login breaker open (${b.fails} consecutive failures) — ` +
|
||||
`retrying after ${new Date(b.openUntil).toISOString()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private breakerFail(account: "tr" | "de"): void {
|
||||
const b = this.loginBreaker[account];
|
||||
b.fails += 1;
|
||||
if (b.fails >= PL24AuthService.BREAKER_THRESHOLD && Date.now() >= b.openUntil) {
|
||||
b.openUntil = Date.now() + PL24AuthService.BREAKER_COOLDOWN_MS;
|
||||
this.logger.warn(
|
||||
`PL24 ${account} login breaker OPEN after ${b.fails} consecutive failures — ` +
|
||||
`pausing login attempts for ${PL24AuthService.BREAKER_COOLDOWN_MS / 60_000}m`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private breakerOk(account: "tr" | "de"): void {
|
||||
const b = this.loginBreaker[account];
|
||||
if (b.fails >= PL24AuthService.BREAKER_THRESHOLD) {
|
||||
this.logger.log(`PL24 ${account} login breaker reset (login succeeded)`);
|
||||
}
|
||||
b.fails = 0;
|
||||
b.openUntil = 0;
|
||||
}
|
||||
|
||||
/** Return access token for the given account. */
|
||||
async getAccessTokenForAccount(account: "tr" | "de"): Promise<string> {
|
||||
async getAccessTokenForAccount(requestedAccount: "tr" | "de"): Promise<string> {
|
||||
const account = this.effectiveAccount(requestedAccount);
|
||||
if (account === "de") {
|
||||
if (!this.tokenData2 || !this.isTokenValid(this.tokenData2)) {
|
||||
await this.login2();
|
||||
@@ -144,7 +202,8 @@ export class PL24AuthService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/** Return session cookie for the given account. */
|
||||
async getSessionCookieForAccount(account: "tr" | "de"): Promise<string> {
|
||||
async getSessionCookieForAccount(requestedAccount: "tr" | "de"): Promise<string> {
|
||||
const account = this.effectiveAccount(requestedAccount);
|
||||
if (account === "de") {
|
||||
if (!this.tokenData2 || !this.isTokenValid(this.tokenData2)) {
|
||||
await this.login2();
|
||||
@@ -160,7 +219,11 @@ export class PL24AuthService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/** Authorize a service catalog for the given account and return the service token. */
|
||||
async authorizeServiceForAccount(serviceName: string, account: "tr" | "de"): Promise<string> {
|
||||
async authorizeServiceForAccount(
|
||||
serviceName: string,
|
||||
requestedAccount: "tr" | "de",
|
||||
): Promise<string> {
|
||||
const account = this.effectiveAccount(requestedAccount);
|
||||
const cache = account === "de" ? this.serviceTokens2 : this.serviceTokens;
|
||||
const cached = cache.get(serviceName);
|
||||
if (cached && cached.expiresAt > new Date()) {
|
||||
@@ -274,7 +337,8 @@ export class PL24AuthService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/** Return PL24TOKEN cookie value for the given account (used as Ford hintstoken param). */
|
||||
getPL24TokenValueForAccount(account: "tr" | "de"): string | null {
|
||||
getPL24TokenValueForAccount(requestedAccount: "tr" | "de"): string | null {
|
||||
const account = this.effectiveAccount(requestedAccount);
|
||||
const data = account === "de" ? this.tokenData2 : this.tokenData;
|
||||
if (!data?.sessionCookie) return null;
|
||||
const match = data.sessionCookie.match(/PL24TOKEN=([^;]+)/);
|
||||
@@ -283,12 +347,13 @@ export class PL24AuthService implements OnModuleInit {
|
||||
|
||||
/** Return ProxyAgent for account 'de', null for 'tr'. */
|
||||
async getProxyAgent4Account(account: "tr" | "de"): Promise<any | null> {
|
||||
if (account !== "de") return null;
|
||||
if (this.effectiveAccount(account) !== "de") return null;
|
||||
return this.getProxyAgent();
|
||||
}
|
||||
|
||||
/** Clear in-memory tokens for the given account. */
|
||||
clearTokensForAccount(account: "tr" | "de"): void {
|
||||
clearTokensForAccount(requestedAccount: "tr" | "de"): void {
|
||||
const account = this.effectiveAccount(requestedAccount);
|
||||
if (account === "de") {
|
||||
this.tokenData2 = null;
|
||||
this.serviceTokens2.clear();
|
||||
@@ -316,6 +381,15 @@ export class PL24AuthService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.trDisabled()) {
|
||||
// Callers normally never reach here (effectiveAccount maps tr → de); this
|
||||
// guards stray/legacy paths so the dead account is never hit while flagged.
|
||||
throw new UnauthorizedException(
|
||||
"PL24 tr login skipped: PL24_TR_DISABLED=true (account inactive on PL24 side)",
|
||||
);
|
||||
}
|
||||
this.breakerGate("tr");
|
||||
|
||||
this.logger.log("Logging in to PL24 (account 1 tr)...");
|
||||
|
||||
const loginRequest: PL24LoginRequest = {
|
||||
@@ -376,8 +450,10 @@ export class PL24AuthService implements OnModuleInit {
|
||||
this.logger.log(
|
||||
`PL24 login (tr) successful. Token expires at ${this.tokenData.expiresAt.toISOString()}`,
|
||||
);
|
||||
this.breakerOk("tr");
|
||||
return this.tokenData;
|
||||
} catch (error) {
|
||||
this.breakerFail("tr");
|
||||
const err = error as Error;
|
||||
if (err.name === "TimeoutError") {
|
||||
throw new UnauthorizedException("PL24 giris zaman asimina ugradi");
|
||||
@@ -447,6 +523,8 @@ export class PL24AuthService implements OnModuleInit {
|
||||
throw new UnauthorizedException("PL24 account 2 (de) credentials not configured");
|
||||
}
|
||||
|
||||
this.breakerGate("de");
|
||||
|
||||
this.logger.log("Logging in to PL24 (account 2 de)...");
|
||||
|
||||
const loginRequest: PL24LoginRequest = {
|
||||
@@ -505,8 +583,10 @@ export class PL24AuthService implements OnModuleInit {
|
||||
this.logger.log(
|
||||
`PL24 login (de) successful. Token expires at ${this.tokenData2.expiresAt.toISOString()}`,
|
||||
);
|
||||
this.breakerOk("de");
|
||||
return this.tokenData2;
|
||||
} catch (error) {
|
||||
this.breakerFail("de");
|
||||
const err = error as Error;
|
||||
if (err.name === "TimeoutError") {
|
||||
throw new UnauthorizedException("PL24 (de) giris zaman asimina ugradi");
|
||||
|
||||
@@ -1917,6 +1917,10 @@ export class PL24FordLegacyService {
|
||||
* Fiat services always use 'de'; others use round-robin via Redis.
|
||||
*/
|
||||
private async resolveAccount(userId?: string, serviceName?: string): Promise<"tr" | "de"> {
|
||||
// PL24_TR_DISABLED=true → the tr account is inactive on PL24's side; route
|
||||
// everyone to de so page fetches, proxies and cache labels all agree with the
|
||||
// auth layer's tr→de mapping (PL24AuthService.effectiveAccount).
|
||||
if (process.env.PL24_TR_DISABLED === "true") return "de";
|
||||
// Fiat always needs de account; Hyundai/Kia/Nissan parts are licensed only on de.
|
||||
if (serviceName && ["fiatp_parts", "fiatt_parts"].includes(serviceName)) {
|
||||
return "de";
|
||||
|
||||
@@ -442,31 +442,49 @@ export class PL24PsaService {
|
||||
|
||||
// ==================== PRIVATE: session + HTTP ====================
|
||||
|
||||
/**
|
||||
* PSA requests must leave from the same egress as their auth account: under
|
||||
* PL24_TR_DISABLED the legacy "tr" auth maps to de, whose session lives behind
|
||||
* the DE proxy — the auth service picks the dispatcher (null for real tr).
|
||||
*/
|
||||
private async psaDispatcher(): Promise<any | null> {
|
||||
return this.authService.getProxyAgent4Account("tr");
|
||||
}
|
||||
|
||||
/**
|
||||
* entry.action → startup=true (302) → 302 with mode+upds. Returns JSESSIONID/mode/upds.
|
||||
*/
|
||||
private async initPsaSession(serviceName: string): Promise<PsaSession | null> {
|
||||
await this.authService.authorizeService(serviceName);
|
||||
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
|
||||
const dispatcher = await this.psaDispatcher();
|
||||
const withDispatcher = (o: RequestInit): RequestInit & { dispatcher?: any } =>
|
||||
dispatcher ? { ...o, dispatcher } : o;
|
||||
try {
|
||||
const entryRes = await fetch(`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
const entryRes = await fetch(
|
||||
`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`,
|
||||
withDispatcher({
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
}),
|
||||
);
|
||||
const jsessionId = entryRes.headers.get("set-cookie")?.match(/JSESSIONID=([^;]+)/)?.[1] || "";
|
||||
const loc1 = entryRes.headers.get("location");
|
||||
if (!loc1) return null;
|
||||
const hdrs2 = jsessionId
|
||||
? { ...headers, Cookie: `${headers.Cookie}; JSESSIONID=${jsessionId}` }
|
||||
: headers;
|
||||
const startupRes = await fetch(loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`, {
|
||||
method: "GET",
|
||||
headers: hdrs2,
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
const startupRes = await fetch(
|
||||
loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`,
|
||||
withDispatcher({
|
||||
method: "GET",
|
||||
headers: hdrs2,
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
}),
|
||||
);
|
||||
const loc2 = startupRes.headers.get("location") || loc1;
|
||||
const mode = loc2.match(/[?&]mode=([^&]+)/)?.[1] || "";
|
||||
// Keep upds URL-encoded exactly as returned (e.g. "2024.02.13+09%3A27%3A21+CET");
|
||||
@@ -494,12 +512,15 @@ export class PL24PsaService {
|
||||
Accept: opts.json ? "application/json,*/*" : "text/html,application/xhtml+xml,*/*;q=0.9",
|
||||
};
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
const dispatcher = await this.psaDispatcher();
|
||||
const fetchOpts: RequestInit & { dispatcher?: any } = {
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: opts.manual ? "manual" : "follow",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
};
|
||||
if (dispatcher) fetchOpts.dispatcher = dispatcher;
|
||||
const res = await fetch(url, fetchOpts);
|
||||
const location = res.headers.get("location");
|
||||
if (opts.manual && res.status >= 300 && res.status < 400) {
|
||||
return { status: res.status, text: null, location };
|
||||
@@ -543,13 +564,19 @@ export class PL24PsaService {
|
||||
Accept: "application/json,image/*,*/*;q=0.9",
|
||||
Referer: `${this.baseUrl}/psa/${serviceName}/vin-image-board.action`,
|
||||
};
|
||||
const dispatcher = await this.psaDispatcher();
|
||||
const withDispatcher = (o: RequestInit): RequestInit & { dispatcher?: any } =>
|
||||
dispatcher ? { ...o, dispatcher } : o;
|
||||
try {
|
||||
const infoRes = await fetch(`${imageUrl}&request=GetImageInfo&cv=1`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
const infoRes = await fetch(
|
||||
`${imageUrl}&request=GetImageInfo&cv=1`,
|
||||
withDispatcher({
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
}),
|
||||
);
|
||||
if (!infoRes.ok) return null;
|
||||
const info = (await infoRes.json()) as {
|
||||
imageWidth: number;
|
||||
@@ -570,12 +597,15 @@ export class PL24PsaService {
|
||||
const getImgUrl =
|
||||
`${imageUrl}&request=GetImage&format=image%2Fpng` +
|
||||
`&bbox=${encodeURIComponent(`0,0,${w},${h}`)}&width=${w}&height=${h}&scalefac=1.0&cv=1&rnd=${rnd}`;
|
||||
const imgRes = await fetch(getImgUrl, {
|
||||
method: "GET",
|
||||
headers: { ...headers, Accept: "image/png,image/*,*/*;q=0.9" },
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
const imgRes = await fetch(
|
||||
getImgUrl,
|
||||
withDispatcher({
|
||||
method: "GET",
|
||||
headers: { ...headers, Accept: "image/png,image/*,*/*;q=0.9" },
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
}),
|
||||
);
|
||||
if (!imgRes.ok) return null;
|
||||
const contentType = imgRes.headers.get("content-type") || "image/png";
|
||||
const buffer = Buffer.from(await imgRes.arrayBuffer());
|
||||
|
||||
Reference in New Issue
Block a user