atagen

malice

public
Star0
Watch0
f12c7de2a3ab83d8b3f716f519bb7fcdee39fe58
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/urls.tsTypescript85 lines2.5 KB
1
export interface UrlPreview {
2
  href: string;
3
  display: string;
4
  hostname: string;
5
  path: string;
6
}
7
8
const MAX_LINKS = 4;
9
const MAX_URL_LENGTH = 2048;
10
const TRAILING_PUNCT = /[.,;:!?'"~…—–-]+$/u;
11
12
function countChar(text: string, char: string): number {
13
  let count = 0;
14
  for (const c of text) if (c === char) count += 1;
15
  return count;
16
}
17
18
function trimUrl(raw: string): string {
19
  let out = raw;
20
  let changed = true;
21
  while (changed && out.length > 0) {
22
    changed = false;
23
    const stripped = out.replace(TRAILING_PUNCT, '');
24
    if (stripped !== out) {
25
      out = stripped;
26
      changed = true;
27
    }
28
    // Strip a trailing closer only when it has no matching opener left in
29
    // the URL itself, so "(https://x/a_(b))" keeps the inner pair and drops
30
    // the outer markdown/paren closer.
31
    const pairs: Array<[string, string]> = [
32
      ['(', ')'],
33
      ['[', ']'],
34
      ['{', '}']
35
    ];
36
    for (const [open, close] of pairs) {
37
      if (out.endsWith(close) && countChar(out, close) > countChar(out, open)) {
38
        out = out.slice(0, -1);
39
        changed = true;
40
      }
41
    }
42
    if (out.endsWith('>')) {
43
      out = out.slice(0, -1);
44
      changed = true;
45
    }
46
  }
47
  return out;
48
}
49
50
/**
51
 * Find http(s) URLs in message text without fetching anything: rendering is
52
 * purely local (hostname/path/link card) so no third-party request can leak
53
 * reader IPs. Callers MUST NOT auto-fetch link targets.
54
 */
55
export function findUrls(text: string): UrlPreview[] {
56
  if (!text || !text.includes('http')) return [];
57
  const out: UrlPreview[] = [];
58
  const seen = new Set<string>();
59
  const pattern = /https?:\/\/[^\s<>"'`\]]+/gi;
60
  let match: RegExpExecArray | null;
61
  while (out.length < MAX_LINKS && (match = pattern.exec(text)) !== null) {
62
    let candidate = match[0];
63
    if (candidate.length > MAX_URL_LENGTH) candidate = candidate.slice(0, MAX_URL_LENGTH);
64
    const trimmed = trimUrl(candidate);
65
    if (trimmed.length < 8) continue;
66
    let parsed: URL | null = null;
67
    try {
68
      parsed = new URL(trimmed);
69
    } catch {
70
      continue;
71
    }
72
    if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue;
73
    const href = parsed.toString();
74
    if (seen.has(href)) continue;
75
    seen.add(href);
76
    const rest = `${parsed.pathname}${parsed.search}${parsed.hash}`;
77
    out.push({
78
      href,
79
      display: trimmed.length > 72 ? `${trimmed.slice(0, 69)}…` : trimmed,
80
      hostname: parsed.hostname,
81
      path: rest && rest !== '/' ? (rest.length > 48 ? `${rest.slice(0, 45)}…` : rest) : ''
82
    });
83
  }
84
  return out;
85
}