atagen

malice

public
Star0
Watch0
06339d2de37a2adbd7c9697702da5360fad61c36
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/urls.tsTypescript97 lines2.8 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
export interface LinkSpan {
51
  start: number;
52
  end: number;
53
  href: string;
54
}
55
56
export function linkSpans(text: string): LinkSpan[] {
57
  if (!text || !text.includes('http')) return [];
58
  const out: LinkSpan[] = [];
59
  for (const match of text.matchAll(/https?:\/\/[^\s<>"'`\]]+/gi)) {
60
    const trimmed = trimUrl(match[0].slice(0, MAX_URL_LENGTH));
61
    if (trimmed.length < 8) continue;
62
    let parsed: URL;
63
    try {
64
      parsed = new URL(trimmed);
65
    } catch {
66
      continue;
67
    }
68
    if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue;
69
    out.push({ start: match.index, end: match.index + trimmed.length, href: parsed.toString() });
70
  }
71
  return out;
72
}
73
74
/**
75
 * Find http(s) URLs in message text without fetching anything: rendering is
76
 * purely local (hostname/path/link card) so no third-party request can leak
77
 * reader IPs. Callers MUST NOT auto-fetch link targets.
78
 */
79
export function findUrls(text: string): UrlPreview[] {
80
  const out: UrlPreview[] = [];
81
  const seen = new Set<string>();
82
  for (const span of linkSpans(text)) {
83
    if (out.length >= MAX_LINKS) break;
84
    const parsed = new URL(span.href);
85
    if (parsed.hostname === 'matrix.to' || seen.has(span.href)) continue;
86
    seen.add(span.href);
87
    const trimmed = text.slice(span.start, span.end);
88
    const rest = `${parsed.pathname}${parsed.search}${parsed.hash}`;
89
    out.push({
90
      href: span.href,
91
      display: trimmed.length > 72 ? `${trimmed.slice(0, 69)}…` : trimmed,
92
      hostname: parsed.hostname,
93
      path: rest && rest !== '/' ? (rest.length > 48 ? `${rest.slice(0, 45)}…` : rest) : ''
94
    });
95
  }
96
  return out;
97
}