File size: 3,264 Bytes
9646e24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90f8168
 
 
9646e24
 
 
 
 
 
 
 
 
 
90f8168
9646e24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

export function formatDate(date: string | Date | null): string {
  if (!date) return "—";
  const d = typeof date === "string" ? new Date(date) : date;
  return d.toLocaleString("en-US", {
    month: "short",
    day: "numeric",
    year: "numeric",
    hour: "2-digit",
    minute: "2-digit",
  });
}

export function formatBytes(bytes: number): string {
  if (bytes === 0) return "0 B";
  const k = 1024;
  const sizes = ["B", "KB", "MB", "GB"];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}

export function truncate(text: string, max: number): string {
  if (text.length <= max) return text;
  return text.slice(0, max) + "...";
}

/**
 * Normalize an origin URL for OAuth redirect URIs.
 * Google OAuth treats `127.0.0.1` and `localhost` as different redirect URIs.
 * `localhost` is allowed for loopback without explicit registration, but
 * `127.0.0.1` must be registered in the Google Cloud Console. This normalizes
 * `127.0.0.1` → `localhost` so the app works regardless of which address is used.
 *
 * If NEXT_PUBLIC_OAUTH_REDIRECT_BASE is set, it overrides the detected origin
 * entirely — useful when the app is accessed through a proxy (e.g. Devin preview)
 * on a different port than what's registered in the Google/Azure console.
 */
export function normalizeOrigin(origin: string): string {
  const envBase = process.env.NEXT_PUBLIC_OAUTH_REDIRECT_BASE;
  const base = typeof envBase === "string" && envBase.trim() ? envBase : origin;
  return base.replace(/127\.0\.0\.1/, "localhost").replace(/\/$/, "");
}

/**
 * Get the external origin from a Next.js request, accounting for reverse proxies
 * (Fly.io, Netlify, etc.) that set X-Forwarded-Host / X-Forwarded-Proto headers.
 */
export function getRequestOrigin(request: { headers: Headers; nextUrl: URL }): string {
  const xfHost = request.headers.get("x-forwarded-host");
  const xfProto = request.headers.get("x-forwarded-proto") || "https";
  const host = xfHost || request.headers.get("host") || request.nextUrl.host;
  return normalizeOrigin(`${xfProto}://${host}`);
}

/**
 * Safely parse a string as JSON. Returns null if the string is empty, null,
 * or not valid JSON (e.g. an HTML error page returned by a serverless
 * function or reverse proxy). This prevents the common
 * "Unexpected token '<'" crash when a fetch returns HTML instead of JSON.
 */
export function safeJson<T = any>(text: string | null | undefined): T | null {
  if (!text) return null;
  const trimmed = text.trim();
  if (!trimmed) return null;
  // Fast reject: JSON never starts with '<' (HTML) or '<!' (doctype)
  if (trimmed[0] === "<") return null;
  try {
    return JSON.parse(trimmed) as T;
  } catch {
    return null;
  }
}

/**
 * Parse a Response body as JSON defensively. Reads text first so we never
 * throw on non-JSON bodies, and returns null instead of crashing.
 */
export async function safeJsonResponse<T = any>(res: Response): Promise<T | null> {
  const text = await res.text().catch(() => "");
  return safeJson<T>(text);
}