atagen

malice

public
Star0
Watch0
e72926bcca9a49b5dce29970ae99748c32cac9f5
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: web/src/lib/urls.ts

973f8fbdaf80atagen2026-09-28 09:10 UTC1
export interface UrlPreview {
973f8fbdaf80atagen2026-09-28 09:10 UTC2
  href: string;
973f8fbdaf80atagen2026-09-28 09:10 UTC3
  display: string;
973f8fbdaf80atagen2026-09-28 09:10 UTC4
  hostname: string;
973f8fbdaf80atagen2026-09-28 09:10 UTC5
  path: string;
973f8fbdaf80atagen2026-09-28 09:10 UTC6
}
973f8fbdaf80atagen2026-09-28 09:10 UTC7
973f8fbdaf80atagen2026-09-28 09:10 UTC8
const MAX_LINKS = 4;
973f8fbdaf80atagen2026-09-28 09:10 UTC9
const MAX_URL_LENGTH = 2048;
973f8fbdaf80atagen2026-09-28 09:10 UTC10
const TRAILING_PUNCT = /[.,;:!?'"~…—–-]+$/u;
973f8fbdaf80atagen2026-09-28 09:10 UTC11
973f8fbdaf80atagen2026-09-28 09:10 UTC12
function countChar(text: string, char: string): number {
973f8fbdaf80atagen2026-09-28 09:10 UTC13
  let count = 0;
973f8fbdaf80atagen2026-09-28 09:10 UTC14
  for (const c of text) if (c === char) count += 1;
973f8fbdaf80atagen2026-09-28 09:10 UTC15
  return count;
973f8fbdaf80atagen2026-09-28 09:10 UTC16
}
973f8fbdaf80atagen2026-09-28 09:10 UTC17
973f8fbdaf80atagen2026-09-28 09:10 UTC18
function trimUrl(raw: string): string {
973f8fbdaf80atagen2026-09-28 09:10 UTC19
  let out = raw;
973f8fbdaf80atagen2026-09-28 09:10 UTC20
  let changed = true;
973f8fbdaf80atagen2026-09-28 09:10 UTC21
  while (changed && out.length > 0) {
973f8fbdaf80atagen2026-09-28 09:10 UTC22
    changed = false;
973f8fbdaf80atagen2026-09-28 09:10 UTC23
    const stripped = out.replace(TRAILING_PUNCT, '');
973f8fbdaf80atagen2026-09-28 09:10 UTC24
    if (stripped !== out) {
973f8fbdaf80atagen2026-09-28 09:10 UTC25
      out = stripped;
973f8fbdaf80atagen2026-09-28 09:10 UTC26
      changed = true;
973f8fbdaf80atagen2026-09-28 09:10 UTC27
    }
973f8fbdaf80atagen2026-09-28 09:10 UTC28
    // Strip a trailing closer only when it has no matching opener left in
973f8fbdaf80atagen2026-09-28 09:10 UTC29
    // the URL itself, so "(https://x/a_(b))" keeps the inner pair and drops
973f8fbdaf80atagen2026-09-28 09:10 UTC30
    // the outer markdown/paren closer.
973f8fbdaf80atagen2026-09-28 09:10 UTC31
    const pairs: Array<[string, string]> = [
973f8fbdaf80atagen2026-09-28 09:10 UTC32
      ['(', ')'],
973f8fbdaf80atagen2026-09-28 09:10 UTC33
      ['[', ']'],
973f8fbdaf80atagen2026-09-28 09:10 UTC34
      ['{', '}']
973f8fbdaf80atagen2026-09-28 09:10 UTC35
    ];
973f8fbdaf80atagen2026-09-28 09:10 UTC36
    for (const [open, close] of pairs) {
973f8fbdaf80atagen2026-09-28 09:10 UTC37
      if (out.endsWith(close) && countChar(out, close) > countChar(out, open)) {
973f8fbdaf80atagen2026-09-28 09:10 UTC38
        out = out.slice(0, -1);
973f8fbdaf80atagen2026-09-28 09:10 UTC39
        changed = true;
973f8fbdaf80atagen2026-09-28 09:10 UTC40
      }
973f8fbdaf80atagen2026-09-28 09:10 UTC41
    }
973f8fbdaf80atagen2026-09-28 09:10 UTC42
    if (out.endsWith('>')) {
973f8fbdaf80atagen2026-09-28 09:10 UTC43
      out = out.slice(0, -1);
973f8fbdaf80atagen2026-09-28 09:10 UTC44
      changed = true;
973f8fbdaf80atagen2026-09-28 09:10 UTC45
    }
973f8fbdaf80atagen2026-09-28 09:10 UTC46
  }
973f8fbdaf80atagen2026-09-28 09:10 UTC47
  return out;
973f8fbdaf80atagen2026-09-28 09:10 UTC48
}
973f8fbdaf80atagen2026-09-28 09:10 UTC49
705b1e1062c2atagen2026-10-23 09:05 UTC50
export interface LinkSpan {
705b1e1062c2atagen2026-10-23 09:05 UTC51
  start: number;
705b1e1062c2atagen2026-10-23 09:05 UTC52
  end: number;
705b1e1062c2atagen2026-10-23 09:05 UTC53
  href: string;
705b1e1062c2atagen2026-10-23 09:05 UTC54
}
705b1e1062c2atagen2026-10-23 09:05 UTC55
705b1e1062c2atagen2026-10-23 09:05 UTC56
export function linkSpans(text: string): LinkSpan[] {
973f8fbdaf80atagen2026-09-28 09:10 UTC57
  if (!text || !text.includes('http')) return [];
705b1e1062c2atagen2026-10-23 09:05 UTC58
  const out: LinkSpan[] = [];
705b1e1062c2atagen2026-10-23 09:05 UTC59
  for (const match of text.matchAll(/https?:\/\/[^\s<>"'`\]]+/gi)) {
705b1e1062c2atagen2026-10-23 09:05 UTC60
    const trimmed = trimUrl(match[0].slice(0, MAX_URL_LENGTH));
973f8fbdaf80atagen2026-09-28 09:10 UTC61
    if (trimmed.length < 8) continue;
705b1e1062c2atagen2026-10-23 09:05 UTC62
    let parsed: URL;
973f8fbdaf80atagen2026-09-28 09:10 UTC63
    try {
973f8fbdaf80atagen2026-09-28 09:10 UTC64
      parsed = new URL(trimmed);
973f8fbdaf80atagen2026-09-28 09:10 UTC65
    } catch {
973f8fbdaf80atagen2026-09-28 09:10 UTC66
      continue;
973f8fbdaf80atagen2026-09-28 09:10 UTC67
    }
973f8fbdaf80atagen2026-09-28 09:10 UTC68
    if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue;
705b1e1062c2atagen2026-10-23 09:05 UTC69
    out.push({ start: match.index, end: match.index + trimmed.length, href: parsed.toString() });
705b1e1062c2atagen2026-10-23 09:05 UTC70
  }
705b1e1062c2atagen2026-10-23 09:05 UTC71
  return out;
705b1e1062c2atagen2026-10-23 09:05 UTC72
}
705b1e1062c2atagen2026-10-23 09:05 UTC73
705b1e1062c2atagen2026-10-23 09:05 UTC74
/**
705b1e1062c2atagen2026-10-23 09:05 UTC75
 * Find http(s) URLs in message text without fetching anything: rendering is
705b1e1062c2atagen2026-10-23 09:05 UTC76
 * purely local (hostname/path/link card) so no third-party request can leak
705b1e1062c2atagen2026-10-23 09:05 UTC77
 * reader IPs. Callers MUST NOT auto-fetch link targets.
705b1e1062c2atagen2026-10-23 09:05 UTC78
 */
705b1e1062c2atagen2026-10-23 09:05 UTC79
export function findUrls(text: string): UrlPreview[] {
705b1e1062c2atagen2026-10-23 09:05 UTC80
  const out: UrlPreview[] = [];
705b1e1062c2atagen2026-10-23 09:05 UTC81
  const seen = new Set<string>();
705b1e1062c2atagen2026-10-23 09:05 UTC82
  for (const span of linkSpans(text)) {
705b1e1062c2atagen2026-10-23 09:05 UTC83
    if (out.length >= MAX_LINKS) break;
705b1e1062c2atagen2026-10-23 09:05 UTC84
    const parsed = new URL(span.href);
705b1e1062c2atagen2026-10-23 09:05 UTC85
    if (parsed.hostname === 'matrix.to' || seen.has(span.href)) continue;
705b1e1062c2atagen2026-10-23 09:05 UTC86
    seen.add(span.href);
705b1e1062c2atagen2026-10-23 09:05 UTC87
    const trimmed = text.slice(span.start, span.end);
973f8fbdaf80atagen2026-09-28 09:10 UTC88
    const rest = `${parsed.pathname}${parsed.search}${parsed.hash}`;
973f8fbdaf80atagen2026-09-28 09:10 UTC89
    out.push({
705b1e1062c2atagen2026-10-23 09:05 UTC90
      href: span.href,
973f8fbdaf80atagen2026-09-28 09:10 UTC91
      display: trimmed.length > 72 ? `${trimmed.slice(0, 69)}…` : trimmed,
973f8fbdaf80atagen2026-09-28 09:10 UTC92
      hostname: parsed.hostname,
973f8fbdaf80atagen2026-09-28 09:10 UTC93
      path: rest && rest !== '/' ? (rest.length > 48 ? `${rest.slice(0, 45)}…` : rest) : ''
973f8fbdaf80atagen2026-09-28 09:10 UTC94
    });
973f8fbdaf80atagen2026-09-28 09:10 UTC95
  }
973f8fbdaf80atagen2026-09-28 09:10 UTC96
  return out;
973f8fbdaf80atagen2026-09-28 09:10 UTC97
}