Kartice poveznica
Kartica poveznice prikazuje poveznicu kao čavrljalice: sliku stranice, naziv web-mjesta, naslov i opis, pročitane iz njezinih OpenGraph oznaka (og:*). To je zaseban blok (linkCard), a ne slika s poveznicom. Sve o karticama nalazi se u alatu za poveznice: klik na karticu odabire je i ističe gumb Poveznica na alatnoj traci, a dvostruki klik otvara dijalog za poveznice s karticom.
Kartica se izrađuje iz poveznice:
- Lijepljenje URL-a u prazan redak zadržava ga kao poveznicu i pokraj nje nudi Poveznica / Kartica.
- Dijalog za poveznice ima prekidač Prikaži kao: Poveznica / Kartica. Kartica prikazuje polja kartice: podaci stranice učitavaju se dok upisujete adresu (ili putem Osvježi podatke), a sliku, naslov, opis, naziv web-mjesta i izgled (kompaktna, velika, bez slike) možete ručno promijeniti. Isti prekidač pretvara postojeću poveznicu u karticu.
- Odabrana kartica otvara se u dijalogu za poveznice (gumb Poveznica ili dvostruki klik) radi uređivanja, a Prikaži kao: Poveznica vraća je u poveznicu.
Dijalog za poveznice uređivača s linkPreview, onako kako se otvara za zalijepljenu adresu. Polja su ovdje samo za čitanje: podaci stranice dolaze s demo krajnje točke, a ne s adrese. Promijenite Prikaži kao i izgled kartice da vidite kako se dijalog mijenja:
I kako kartica izgleda u uređivaču, u svakom izgledu.
Kompaktna, slika lijevo:
Velika, slika odozgo:
Bez slike, samo tekstovi:
Zašto backend krajnja točka
Preglednik ne može pročitati stranicu drugog web-mjesta: CORS blokira zahtjev osim ako ga to web-mjesto dopusti, a većina ne dopušta. Zato tiptapify pita vašu krajnju točku, koja stranicu preuzima na poslužitelju i odgovara njezinim OpenGraph podacima.
<Tiptapify
:link-preview="{ endpoint: '/api/link-preview', headers: { Authorization: `Bearer ${token}` } }"
/>Bez linkPreview, ili dok njegova krajnja točka ne odgovori na handshake, ništa ne nudi karticu: lijepljenje URL-a stvara običnu poveznicu, a dijalog za poveznice nema prekidač Prikaz. Kartice koje su već u sadržaju ostaju uredive.
Ugovor krajnje točke
Jedna krajnja točka odgovara na dva GET zahtjeva (poslana sa zaglavljem Accept: application/json i vašim headers).
Handshake
GET /api/link-preview — bez url. Uređivač ga šalje jednom po krajnjoj točki i učitavanju stranice (dijele ga svi uređivači na stranici) te još jednom kad se otvori dijalog za poveznice ako nije uspio (krajnja točka je možda u međuvremenu proradila). Ne smije dohvatiti nijednu stranicu i mora odgovoriti:
HTTP/1.1 200 OK
Content-Type: application/json
{ "tiptapifyLinkPreview": 1 }1 je verzija ovog ugovora. Običan 200 ili 204 namjerno nije dovoljan: SPA fallback ili stranica za prijavu odgovara 200 na bilo koju putanju.
Pregled
GET /api/link-preview?url=https%3A%2F%2Fexample.com%2Fblog%2Fgetting-started odgovara podacima stranice. Sva polja osim url su neobavezna; prazna se zanemaruju:
{
"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"
}Pogreška odgovara statusom 4xx/5xx s { "error": "…" }; dijalog prikazuje poruku i ostavlja poveznicu kakva jest. GET omogućuje vašem poslužitelju ili CDN-u da odgovore sprema u predmemoriju prema url.
Uz krajnju točku za prijenos tiptapify s njom pohranjuje i sliku stranice (stranica je poslije može promijeniti ili ukloniti). Preglednik tu sliku može pročitati samo kad njezin host dopušta CORS; inače kartica povezuje na izvornu sliku. Vaša krajnja točka može vratiti i image koji je već sama pohranila.
Spremljeni HTML
<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 je compact (slika lijevo), large (slika gore) ili text (bez slike). Cijela kartica jedna je poveznica, pa radi na web-mjestu bez ikakve skripte. Njezin izgled dolazi s HTML-om prema zadanim postavkama kao ugrađeni stilovi, ili iz tiptapify/content.css ako se spremaju samo klase: vidi Stilovi sadržaja.
Primjeri krajnjih točaka
Primjeri u nastavku ponašaju se jednako, pa odaberite onaj za svoj stack:
GETbezurlodgovara na handshake i ne dohvaća ništa.- Čitaju se samo javne
http(s)adrese (bez SSRF-a): svaka adresa hosta mora biti javna, ne privatna mreža, loopback, link-local (metapodaci oblaka) ni rezervirana, inače je odgovor422. - Preusmjeravanja se prate ručno, do 3, a adresa svakog od njih ponovno se provjerava. Primjeri za PHP, Laravel i Node.js spajaju se na točno onu adresu koju su provjerili, pa se DNS odgovor ne može promijeniti između provjere i zahtjeva.
- Stranica se čita s vremenskim ograničenjem od 5 sekundi, samo njezin prvi megabajt (dovoljan je head; Laravel primjer odbija stranice veće od 5 MB).
- Podaci dolaze iz oznaka
og:*, zatimtwitter:*,<meta name="description">,<title>i naziva hosta; slika se pretvara u apsolutnu i zadržava samo kad jehttp(s). - Stranica koja se ne može pročitati (statusna pogreška, istek vremena, preusmjeravanje na internu adresu) odgovara s
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 zahtijeva 8.2+ s ekstenzijama curl i dom, Node.js 18+, Python 3.9+; nijednom nije potreban paket osim frameworka.
U produkciji
- Zaštitite krajnju točku. Preuzima svaku stranicu koju se od nje zatraži: stavite je iza autentifikacije (uređivač sa svakim zahtjevom šalje
headersizlinkPreview) i ograničenja broja zahtjeva, kao u Laravel ruti iznad. - Spremajte odgovore u predmemoriju prema
url, kao Laravel primjer: uređivač pita ponovno kad god se kartica na stranici izradi ili osvježi. - Isto podrijetlo ili CORS. Krajnjoj točki na istom podrijetlu kao uređivač ne treba ništa dodatno; na drugom podrijetlu dopustite podrijetlo uređivača i zaglavlja koja šaljete.
- Python primjer provjerava host, a zatim
urllibponovno razrješava njegovu adresu, pa bi host koji drugi put drukčije odgovori na DNS mogao proći. Za javni servis spojite se na provjerenu adresu (to moguhttpxtransport ilirequestsadapter) ili zahtjeve šaljite kroz egress proxy koji blokira interne raspone. - Kodiranja. Python primjer dekodira stranicu prema charsetu iz njezina
Content-Type; PHP i Node.js primjeri čitaju je kao UTF-8, što pokriva većinu web-mjesta. Dekodirajte i prema charsetu ako prikazujete stranice u starijim kodiranjima.