/* 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 <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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;
}