Tarjetas de enlace
Una tarjeta de enlace muestra un enlace como lo hacen las aplicaciones de mensajería: la imagen de la página, el nombre del sitio, el título y la descripción, leídos de sus etiquetas OpenGraph (og:*). Es un bloque propio (linkCard), no una imagen con un enlace. Todo lo relativo a las tarjetas está en la herramienta de enlace: un clic en una tarjeta la selecciona y resalta el botón Enlace de la barra de herramientas, y un doble clic abre el diálogo de enlace con la tarjeta.
Una tarjeta se crea a partir de un enlace:
- Pegar una URL en una línea vacía la mantiene como enlace y ofrece Enlace / Tarjeta junto a ella.
- El diálogo de enlace tiene un interruptor Mostrar como: Enlace / Tarjeta. Tarjeta muestra los campos de la tarjeta: los datos de la página se cargan mientras escribes la dirección (o con Actualizar datos), y la imagen, el título, la descripción, el nombre del sitio y el diseño (compacta, grande, sin imagen) se pueden cambiar a mano. El mismo interruptor convierte un enlace existente en una tarjeta.
- Una tarjeta seleccionada se abre en el diálogo de enlace (botón Enlace o doble clic) para editarla, y Mostrar como: Enlace la convierte de nuevo en un enlace.
El diálogo de enlace de un editor con linkPreview, tal como se abre para una dirección pegada. Aquí los campos son de solo lectura: los datos de la página proceden de un endpoint de demostración, no de la dirección. Cambia Mostrar como y el diseño de la tarjeta para ver cómo cambia el diálogo:
Y así se ve una tarjeta en el editor, en cada diseño.
Compacta, con la imagen a la izquierda:
Grande, con la imagen arriba:
Sin imagen, solo los textos:
Por qué un endpoint de backend
Un navegador no puede leer la página de otro sitio: CORS bloquea la solicitud a menos que ese sitio lo permita, y la mayoría no lo hace. Por eso tiptapify consulta a tu endpoint, que descarga la página en el servidor y responde con sus datos OpenGraph.
<Tiptapify
:link-preview="{ endpoint: '/api/link-preview', headers: { Authorization: `Bearer ${token}` } }"
/>Sin linkPreview, o mientras su endpoint no responde al handshake, nada ofrece una tarjeta: al pegar una URL se crea un enlace simple y el diálogo de enlace no tiene el interruptor Visualización. Las tarjetas que ya están en el contenido siguen siendo editables.
El contrato del endpoint
Un endpoint responde a dos solicitudes GET (enviadas con Accept: application/json y tus headers).
Handshake
GET /api/link-preview, sin url. El editor lo envía una vez por endpoint y carga de página (todos los editores de la página lo comparten), y una vez más al abrir el diálogo de enlace si falló (puede que el endpoint ya esté activo). No debe descargar ninguna página y debe responder:
HTTP/1.1 200 OK
Content-Type: application/json
{ "tiptapifyLinkPreview": 1 }1 es la versión de este contrato. Un simple 200 o 204 no basta a propósito: el fallback de una SPA o una página de inicio de sesión responde 200 a cualquier ruta.
Vista previa
GET /api/link-preview?url=https%3A%2F%2Fexample.com%2Fblog%2Fgetting-started responde con los datos de la página. Todos los campos salvo url son opcionales; los vacíos se ignoran:
{
"url": "https://example.com/blog/getting-started",
"title": "Getting started",
"description": "Install the package and create your first page.",
"image": "https://example.com/images/getting-started.png",
"siteName": "Example"
}Un error responde con un estado 4xx/5xx y { "error": "…" }; el diálogo muestra el mensaje y deja el enlace como está. GET permite que tu servidor o CDN almacene en caché las respuestas por url.
Con un endpoint de subida, tiptapify también guarda en él la imagen de la página (una página puede cambiarla o quitarla más adelante). Un navegador solo puede leer esa imagen cuando su host permite CORS; de lo contrario, la tarjeta enlaza la imagen original. Tu endpoint también puede devolver una image que ya haya guardado él mismo.
HTML guardado
<a class="tiptapify-link-card" data-link-card="compact" href="https://example.com/blog/getting-started" target="_blank" rel="noopener noreferrer">
<img class="tiptapify-link-card__image" src="https://example.com/images/getting-started.png" alt="">
<span class="tiptapify-link-card__body">
<span class="tiptapify-link-card__site">Example</span>
<span class="tiptapify-link-card__title">Getting started</span>
<span class="tiptapify-link-card__description">Install the package and create your first page.</span>
</span>
</a>data-link-card es compact (imagen a la izquierda), large (imagen arriba) o text (sin imagen). Toda la tarjeta es un único enlace, así que funciona en un sitio sin ningún script. Su aspecto llega por defecto con el HTML como estilos en línea, o procede de tiptapify/content.css si son solo clases: véase Estilos de contenido.
Endpoints de ejemplo
Los ejemplos siguientes se comportan igual, así que elige el de tu stack:
GETsinurlresponde al handshake y no descarga nada.- Solo se leen direcciones
http(s)públicas (sin SSRF): todas las direcciones del host deben ser públicas, no de una red privada, de loopback, de enlace local (metadatos de la nube) ni reservadas; de lo contrario, la respuesta es422. - Las redirecciones se siguen a mano, hasta 3, y la dirección de cada una se vuelve a comprobar. Los ejemplos de PHP, Laravel y Node.js se conectan a la misma dirección que comprobaron, de modo que una respuesta DNS no puede cambiar entre la comprobación y la solicitud.
- La página se lee con un tiempo de espera de 5 segundos y solo su primer megabyte (el head basta; el ejemplo de Laravel rechaza páginas de más de 5 MB).
- Los datos proceden de las etiquetas
og:*, luegotwitter:*,<meta name="description">,<title>y el nombre del host; la imagen se convierte en absoluta y solo se conserva si eshttp(s). - Una página que no se puede leer (un estado de error, un tiempo de espera agotado, una redirección a una dirección interna) responde
502.
<?php
// link-preview.php: GET /link-preview.php answers the handshake, GET /link-preview.php?url=… the page data.
// PHP 8.2+ with the curl and dom extensions.
declare(strict_types=1);
const TIMEOUT = 5;
const MAX_REDIRECTS = 3;
const MAX_BYTES = 1_000_000;
function reply(int $status, array $body): never
{
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
/**
* The curl `RESOLVE` entry of a public http(s) URL, null for anything else. Never let the endpoint reach an
* internal address (SSRF): every address of the host must be public, and curl connects to the one checked.
*/
function publicTarget(string $url): ?string
{
$parts = parse_url($url);
$scheme = strtolower($parts['scheme'] ?? '');
$host = trim($parts['host'] ?? '', '[]');
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
return null;
}
$ips = filter_var($host, FILTER_VALIDATE_IP)
? [$host]
: [...(gethostbynamel($host) ?: []), ...array_column(@dns_get_record($host, DNS_AAAA) ?: [], 'ipv6')];
foreach ($ips as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) === false) {
return null;
}
}
if ($ips === []) {
return null;
}
$port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80);
$ip = str_contains($ips[0], ':') ? "[{$ips[0]}]" : $ips[0];
return "{$host}:{$port}:{$ip}";
}
/** The HTML of a page (its first MAX_BYTES) and its address after redirects, each redirect checked again */
function fetchPage(string $url): ?array
{
for ($redirects = 0; $redirects <= MAX_REDIRECTS; $redirects++) {
$target = publicTarget($url);
if ($target === null) {
return null;
}
$html = '';
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_RESOLVE => [$target],
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_TIMEOUT => TIMEOUT,
CURLOPT_ENCODING => '',
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; LinkPreview/1.0)',
CURLOPT_HTTPHEADER => ['Accept: text/html'],
// The head of a page is enough: stop reading after MAX_BYTES (a shorter return aborts the transfer)
CURLOPT_WRITEFUNCTION => function ($curl, string $chunk) use (&$html): int {
$html .= $chunk;
return strlen($html) > MAX_BYTES ? 0 : strlen($chunk);
},
]);
curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$location = curl_getinfo($curl, CURLINFO_REDIRECT_URL);
if ($status >= 300 && $status < 400 && $location) {
$url = $location;
continue;
}
return $status === 200 ? [$html, $url] : null;
}
return null;
}
/** An address of the page made absolute; an empty string for anything but http(s) */
function absoluteUrl(string $href, string $base): string
{
if ($href === '' || preg_match('~^https?://~i', $href)) {
return $href;
}
$parts = parse_url($base);
$origin = $parts['scheme'].'://'.$parts['host'].(isset($parts['port']) ? ':'.$parts['port'] : '');
return match (true) {
str_starts_with($href, '//') => $parts['scheme'].':'.$href,
str_starts_with($href, '/') => $origin.$href,
(bool) preg_match('~^[a-z][a-z0-9+.-]*:~i', $href) => '',
default => $origin.preg_replace('~/[^/]*$~', '/', $parts['path'] ?? '/').$href,
};
}
/** @return array<string, string> */
function openGraph(string $html, string $url): array
{
$document = new DOMDocument();
@$document->loadHTML('<?xml encoding="utf-8" ?>'.$html, LIBXML_NONET);
$meta = [];
foreach ($document->getElementsByTagName('meta') as $tag) {
$key = strtolower($tag->getAttribute('property') ?: $tag->getAttribute('name'));
$content = trim($tag->getAttribute('content'));
if ($key !== '' && $content !== '') {
$meta[$key] ??= $content;
}
}
$title = trim((string) $document->getElementsByTagName('title')->item(0)?->textContent);
return array_filter([
'title' => $meta['og:title'] ?? $meta['twitter:title'] ?? $title,
'description' => $meta['og:description'] ?? $meta['twitter:description'] ?? $meta['description'] ?? '',
'image' => absoluteUrl($meta['og:image'] ?? $meta['twitter:image'] ?? '', $url),
'siteName' => $meta['og:site_name'] ?? (string) parse_url($url, PHP_URL_HOST),
]);
}
$url = trim((string) ($_GET['url'] ?? ''));
// The handshake: no url, no page fetched
if ($url === '') {
reply(200, ['tiptapifyLinkPreview' => 1]);
}
if (publicTarget($url) === null) {
reply(422, ['error' => 'Only public http(s) pages have a preview.']);
}
$page = fetchPage($url);
if ($page === null) {
reply(502, ['error' => 'The page did not answer.']);
}
[$html, $pageUrl] = $page;
reply(200, ['url' => $url] + openGraph($html, $pageUrl));<?php
// routes/api.php:
// Route::get('/link-preview', LinkPreviewController::class)->middleware(['auth:sanctum', 'throttle:60,1']);
declare(strict_types=1);
namespace App\Http\Controllers;
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
final class LinkPreviewController
{
private const MAX_REDIRECTS = 3;
private const MAX_BYTES = 5_000_000;
public function __invoke(Request $request): JsonResponse
{
// The handshake: no url, no page fetched
if (! $request->filled('url')) {
return response()->json(['tiptapifyLinkPreview' => 1]);
}
$url = (string) $request->query('url');
if ($this->publicTarget($url) === null) {
return response()->json(['error' => 'Only public http(s) pages have a preview.'], 422);
}
// The page data is cached, not the response; a failed page (null) is asked again next time
$preview = Cache::remember('link-preview:'.sha1($url), now()->addDay(), fn (): ?array => $this->preview($url));
return $preview === null
? response()->json(['error' => 'The page did not answer.'], 502)
: response()->json(['url' => $url] + $preview);
}
/** @return array<string, string>|null */
private function preview(string $url): ?array
{
for ($redirects = 0; $redirects <= self::MAX_REDIRECTS; $redirects++) {
// Every redirect is checked again, and curl connects to the address checked
$target = $this->publicTarget($url);
if ($target === null) {
return null;
}
try {
$response = Http::timeout(5)
->withUserAgent('Mozilla/5.0 (compatible; LinkPreview/1.0)')
->accept('text/html')
->withOptions([
'allow_redirects' => false,
'curl' => [CURLOPT_RESOLVE => [$target], CURLOPT_MAXFILESIZE => self::MAX_BYTES],
])
->get($url);
} catch (ConnectionException) {
return null;
}
if ($response->redirect() && $response->header('Location') !== '') {
$url = (string) UriResolver::resolve(new Uri($url), new Uri($response->header('Location')));
continue;
}
return $response->successful() ? $this->openGraph($response->body(), $url) : null;
}
return null;
}
/**
* The curl `RESOLVE` entry of a public http(s) URL, null for anything else. Never let the endpoint reach
* an internal address (SSRF): every address of the host must be public.
*/
private function publicTarget(string $url): ?string
{
$parts = parse_url($url);
$scheme = strtolower($parts['scheme'] ?? '');
$host = trim($parts['host'] ?? '', '[]');
if (! in_array($scheme, ['http', 'https'], true) || $host === '') {
return null;
}
$ips = filter_var($host, FILTER_VALIDATE_IP)
? [$host]
: [...(gethostbynamel($host) ?: []), ...array_column(@dns_get_record($host, DNS_AAAA) ?: [], 'ipv6')];
if ($ips === [] || in_array(false, array_map(fn (string $ip) => filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE), $ips), true)) {
return null;
}
$port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80);
$ip = str_contains($ips[0], ':') ? "[{$ips[0]}]" : $ips[0];
return "{$host}:{$port}:{$ip}";
}
/** @return array<string, string> */
private function openGraph(string $html, string $url): array
{
$document = new \DOMDocument();
@$document->loadHTML('<?xml encoding="utf-8" ?>'.$html, LIBXML_NONET);
$meta = [];
foreach ($document->getElementsByTagName('meta') as $tag) {
$key = strtolower($tag->getAttribute('property') ?: $tag->getAttribute('name'));
$content = trim($tag->getAttribute('content'));
if ($key !== '' && $content !== '') {
$meta[$key] ??= $content;
}
}
$title = trim((string) $document->getElementsByTagName('title')->item(0)?->textContent);
$image = $meta['og:image'] ?? $meta['twitter:image'] ?? null;
$image = $image === null ? '' : (string) UriResolver::resolve(new Uri($url), new Uri($image));
return array_filter([
'title' => $meta['og:title'] ?? $meta['twitter:title'] ?? $title,
'description' => $meta['og:description'] ?? $meta['twitter:description'] ?? $meta['description'] ?? '',
'image' => preg_match('~^https?://~i', $image) ? $image : '',
'siteName' => $meta['og:site_name'] ?? (string) parse_url($url, PHP_URL_HOST),
]);
}
}// link-preview.mjs: `node link-preview.mjs`, then GET http://localhost:3001/link-preview[?url=…]. Node 18+, no packages.
import dns from 'node:dns'
import http from 'node:http'
import https from 'node:https'
import net from 'node:net'
const TIMEOUT = 5000
const MAX_REDIRECTS = 3
const MAX_BYTES = 1_000_000
// Never let the endpoint reach an internal address (SSRF): private, loopback, link-local and reserved ranges
// (an IPv4-mapped IPv6 address, ::ffff:127.0.0.1, is checked against the IPv4 ranges)
const blocked = new net.BlockList()
for (const [address, prefix] of [
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
['192.0.0.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['224.0.0.0', 3],
]) {
blocked.addSubnet(address, prefix, 'ipv4')
}
for (const [address, prefix] of [['::', 127], ['64:ff9b::', 96], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8]]) {
blocked.addSubnet(address, prefix, 'ipv6')
}
const isPublic = (address, family) => !blocked.check(address, family === 6 || family === 'ipv6' ? 'ipv6' : 'ipv4')
// The DNS lookup of every request: it refuses a host with an internal address, so the address checked is the one used
function publicLookup(hostname, options, callback) {
dns.lookup(hostname, { ...options, all: true }, (error, addresses) => {
if (error) {
return callback(error)
}
if (!addresses.length || !addresses.every(({ address, family }) => isPublic(address, family))) {
return callback(Object.assign(new Error(`${hostname} is not a public host`), { code: 'ENOTPUBLIC' }))
}
return options.all ? callback(null, addresses) : callback(null, addresses[0].address, addresses[0].family)
})
}
// A public http(s) address, or null (a host given as an IP skips the lookup, so it is checked here)
function publicUrl(value, base) {
try {
const url = new URL(value, base)
const host = url.hostname.replace(/^\[|\]$/g, '')
if (!['http:', 'https:'].includes(url.protocol) || (net.isIP(host) && !isPublic(host, net.isIP(host)))) {
return null
}
return url
} catch {
return null
}
}
// One request: a redirect, or the first MAX_BYTES of the page
function request(url) {
return new Promise((resolve, reject) => {
const client = url.protocol === 'https:' ? https : http
const headers = { 'User-Agent': 'Mozilla/5.0 (compatible; LinkPreview/1.0)', Accept: 'text/html' }
const req = client.get(url, { headers, lookup: publicLookup, timeout: TIMEOUT }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume()
return resolve({ location: res.headers.location })
}
if (res.statusCode !== 200) {
res.resume()
return reject(new Error(`The page answered ${res.statusCode}.`))
}
let html = ''
res.setEncoding('utf8')
res.on('data', (chunk) => {
html += chunk
// The head of a page is enough
if (html.length > MAX_BYTES) {
res.destroy()
}
})
res.on('close', () => resolve({ html }))
})
req.on('timeout', () => req.destroy(new Error('The page did not answer in time.')))
req.on('error', reject)
})
}
// The HTML of a page and its address after redirects, each redirect checked again. A redirect to an internal
// address fails like a page that does not answer: the address asked for was public
async function fetchPage(url) {
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
const { location, html } = await request(url).catch((error) => {
throw redirects && error.code === 'ENOTPUBLIC' ? new Error('The page redirects to an internal address.') : error
})
if (html !== undefined) {
return { html, url }
}
url = publicUrl(location, url)
if (!url) {
throw new Error('The page redirects to an internal address.')
}
}
throw new Error('Too many redirects.')
}
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: '\'', nbsp: ' ' }
const decode = text => text.replace(/&(?:#(\d+)|#x([\da-f]+)|(\w+));/gi, (entity, decimal, hex, name) => (
decimal ? String.fromCodePoint(Number(decimal)) : hex ? String.fromCodePoint(parseInt(hex, 16)) : ENTITIES[name.toLowerCase()] ?? entity
))
function openGraph(html, url) {
const meta = {}
for (const [tag] of html.matchAll(/<meta\b[^>]*>/gi)) {
const attrs = {}
for (const [, name, double, single, bare] of tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) {
attrs[name.toLowerCase()] = double ?? single ?? bare
}
const key = (attrs.property || attrs.name || '').toLowerCase()
const content = decode(attrs.content ?? '').trim()
if (key && content && !(key in meta)) {
meta[key] = content
}
}
const title = decode(html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1] ?? '').trim()
const image = meta['og:image'] || meta['twitter:image']
return Object.fromEntries(Object.entries({
title: meta['og:title'] || meta['twitter:title'] || title,
description: meta['og:description'] || meta['twitter:description'] || meta.description,
image: image && publicUrl(image, url) ? new URL(image, url).href : undefined,
siteName: meta['og:site_name'] || url.hostname,
}).filter(([, value]) => value))
}
http.createServer(async (req, res) => {
const send = (status, body) => {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
const { pathname, searchParams } = new URL(req.url, 'http://localhost')
if (req.method !== 'GET' || pathname !== '/link-preview') {
return send(404, { error: 'Not found.' })
}
// The handshake: no url, no page fetched
const value = searchParams.get('url')
if (!value) {
return send(200, { tiptapifyLinkPreview: 1 })
}
const url = publicUrl(value)
if (!url) {
return send(422, { error: 'Only public http(s) pages have a preview.' })
}
try {
const page = await fetchPage(url)
send(200, { url: value, ...openGraph(page.html, page.url) })
} catch (error) {
error.code === 'ENOTPUBLIC'
? send(422, { error: 'Only public http(s) pages have a preview.' })
: send(502, { error: 'The page did not answer.' })
}
}).listen(3001)# link_preview.py: `python3 link_preview.py`, then GET http://localhost:3002/link-preview[?url=…]. Python 3.9+, no packages.
import ipaddress
import json
import socket
import urllib.error
import urllib.request
from html.parser import HTMLParser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urljoin, urlsplit
TIMEOUT = 5
MAX_REDIRECTS = 3
MAX_BYTES = 1_000_000
def is_public_url(url):
"""Never let the endpoint reach an internal address (SSRF): every address of the host must be global."""
parts = urlsplit(url)
if parts.scheme not in ('http', 'https') or not parts.hostname:
return False
try:
addresses = {info[4][0] for info in socket.getaddrinfo(parts.hostname, parts.port or None)}
except (socket.gaierror, UnicodeError, ValueError):
return False
return bool(addresses) and all(ipaddress.ip_address(address.split('%')[0]).is_global for address in addresses)
class NoRedirects(urllib.request.HTTPRedirectHandler):
"""Redirects come back as a response, so the address each one points to is checked first."""
def redirect_request(self, *args, **kwargs):
return None
opener = urllib.request.build_opener(NoRedirects)
def fetch_page(url):
"""The HTML of a page (its first MAX_BYTES) and its address after redirects, each redirect checked again.
A redirect to an internal address fails like a page that does not answer: the address asked for was public."""
for redirects in range(MAX_REDIRECTS + 1):
if not is_public_url(url):
raise OSError('The page redirects to an internal address.') if redirects else PermissionError(url)
request = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0 (compatible; LinkPreview/1.0)',
'Accept': 'text/html',
})
try:
with opener.open(request, timeout=TIMEOUT) as response:
charset = response.headers.get_content_charset() or 'utf-8'
return response.read(MAX_BYTES).decode(charset, errors='replace'), url
except urllib.error.HTTPError as error:
location = error.headers.get('Location')
if not (300 <= error.code < 400 and location):
raise
url = urljoin(url, location)
raise OSError('Too many redirects.')
class OpenGraph(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.meta = {}
self.title = ''
self.in_title = False
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == 'meta':
key = (attrs.get('property') or attrs.get('name') or '').lower()
content = (attrs.get('content') or '').strip()
if key and content:
self.meta.setdefault(key, content)
self.in_title = tag == 'title'
def handle_endtag(self, tag):
if tag == 'title':
self.in_title = False
def handle_data(self, data):
if self.in_title:
self.title += data
def open_graph(html, url):
parser = OpenGraph()
parser.feed(html)
meta = parser.meta
image = meta.get('og:image') or meta.get('twitter:image')
image = urljoin(url, image) if image else ''
preview = {
'title': meta.get('og:title') or meta.get('twitter:title') or parser.title.strip(),
'description': meta.get('og:description') or meta.get('twitter:description') or meta.get('description'),
'image': image if urlsplit(image).scheme in ('http', 'https') else '',
'siteName': meta.get('og:site_name') or urlsplit(url).hostname,
}
return {key: value for key, value in preview.items() if value}
class LinkPreview(BaseHTTPRequestHandler):
def send(self, status, body):
payload = json.dumps(body, ensure_ascii=False).encode()
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self):
parts = urlsplit(self.path)
if parts.path != '/link-preview':
return self.send(404, {'error': 'Not found.'})
# The handshake: no url, no page fetched
url = parse_qs(parts.query).get('url', [''])[0].strip()
if not url:
return self.send(200, {'tiptapifyLinkPreview': 1})
try:
html, page_url = fetch_page(url)
except PermissionError:
return self.send(422, {'error': 'Only public http(s) pages have a preview.'})
except (OSError, ValueError):
return self.send(502, {'error': 'The page did not answer.'})
self.send(200, {'url': url, **open_graph(html, page_url)})
ThreadingHTTPServer(('', 3002), LinkPreview).serve_forever()PHP necesita la versión 8.2+ con las extensiones curl y dom, Node.js 18+ y Python 3.9+; ninguno necesita más paquetes que el propio framework.
En producción
- Protege el endpoint. Descarga cualquier página que se le pida: ponlo detrás de tu autenticación (el editor envía los
headersdelinkPreviewcon cada solicitud) y de un límite de peticiones, como en la ruta de Laravel anterior. - Almacena en caché las respuestas por
url, como hace el ejemplo de Laravel: el editor vuelve a preguntar cada vez que se crea o actualiza una tarjeta de la página. - Mismo origen o CORS. Un endpoint en el mismo origen que el editor no necesita nada más; en otro origen, permite el origen del editor y los encabezados que envías.
- El ejemplo de Python comprueba el host y luego deja que
urlliblo resuelva de nuevo, así que un host que responda de forma distinta al DNS la segunda vez podría colarse. Para un servicio público, conéctate a la dirección comprobada (un transporte dehttpxo un adaptador derequestspuede hacerlo) o envía las solicitudes a través de un proxy de salida que bloquee los rangos internos. - Codificaciones. El ejemplo de Python decodifica la página según el charset de su
Content-Type; los de PHP y Node.js la leen como UTF-8, lo que cubre la mayoría de los sitios. Decodifica también según el charset si muestras páginas con codificaciones antiguas.