/* See LICENSE file for copyright and license details. * * URL extraction from text content. * * Strategy: scan for "http://" and "https://" anchors, * then greedily extend the match character by character * until hitting a character that cannot be part of a URL. */ #include #include #include #include #include "config.h" #include "extract.h" #include "util.h" /* * Characters that are valid in a URL. * RFC 3986: unreserved / pct-encoded / sub-delims / ":" / "@" * / "/" / "?" / "#" / "[" / "]" * * We exclude common trailing punctuation that typically isn't * part of the URL (periods, commas, parens when unbalanced, * angle brackets, quotes). */ static int is_url_char(unsigned char c) { if (isalnum(c)) return 1; switch (c) { case '-': case '.': case '_': case '~': /* unreserved */ case ':': case '/': case '?': case '#': /* gen-delims */ case '[': case ']': case '@': case '!': case '$': case '&': case '\'': /* sub-delims */ case '(': case ')': case '*': case '+': case ',': case ';': case '=': case '%': /* pct-encoded */ return 1; default: return 0; } } /* * Strip trailing punctuation that is commonly not part of URLs * when they appear in prose text. E.g.: * "Visit https://example.com." -> strip trailing "." * "(see https://example.com)" -> strip trailing ")" * "https://example.com," -> strip trailing "," */ static size_t strip_trailing(const char *url, size_t len) { int parens; size_t i; while (len > 0) { unsigned char c = url[len - 1]; /* Always strip trailing periods, commas, semicolons, * colons, exclamation marks */ if (c == '.' || c == ',' || c == ';' || c == ':' || c == '!' || c == '\'') { len--; continue; } /* Strip trailing ) only if unbalanced */ if (c == ')') { parens = 0; for (i = 0; i < len; i++) { if (url[i] == '(') parens++; else if (url[i] == ')') parens--; } if (parens < 0) { len--; continue; } } /* Strip trailing ] only if unbalanced */ if (c == ']') { parens = 0; for (i = 0; i < len; i++) { if (url[i] == '[') parens++; else if (url[i] == ']') parens--; } if (parens < 0) { len--; continue; } } /* Strip trailing > (common in angle-bracket URLs) */ if (c == '>') { len--; continue; } break; } return len; } /* * Extract a single URL starting at the given position. * Returns the length of the URL, or 0 if invalid. */ static size_t extract_one(const char *data, size_t pos, size_t total_len) { size_t start, len; start = pos; len = 0; /* Must start with http:// or https:// */ if (total_len - pos >= 8 && strncmp(data + pos, "https://", 8) == 0) { len = 8; } else if (total_len - pos >= 7 && strncmp(data + pos, "http://", 7) == 0) { len = 7; } else { return 0; } /* Greedily extend while characters are valid URL chars */ while (start + len < total_len && is_url_char((unsigned char)data[start + len])) { len++; if (len >= MAX_URL_LEN) break; } /* Must have something after the protocol */ if ((data[start + 4] == 's' && len <= 8) || len <= 7) return 0; /* Strip trailing punctuation */ len = strip_trailing(data + start, len); return len; } void extract_urls(const char *data, size_t len, UrlCallback cb, void *ctx) { size_t pos, url_len; char *url; pos = 0; while (pos < len) { /* Scan for http:// or https:// */ if (data[pos] != 'h') { pos++; continue; } if (pos + 7 > len) { pos++; continue; } if (strncmp(data + pos, "http://", 7) != 0 && strncmp(data + pos, "https://", 8) != 0) { pos++; continue; } url_len = extract_one(data, pos, len); if (url_len == 0) { pos++; continue; } /* Copy URL and deliver via callback */ url = xmalloc(url_len + 1); memcpy(url, data + pos, url_len); url[url_len] = '\0'; cb(url, ctx); free(url); pos += url_len; } } int is_binary(const char *data, size_t len) { size_t i, check_len; /* Check first 8KB for null bytes */ check_len = len < 8192 ? len : 8192; for (i = 0; i < check_len; i++) { if (data[i] == '\0') return 1; } return 0; }