feat: add Open Graph and X Card meta tags with server-side crawler proxy

This commit is contained in:
2026-02-07 16:42:13 +01:00
parent 9ad14231ee
commit 2f964b09a0
5 changed files with 192 additions and 5 deletions

View File

@@ -79,7 +79,8 @@ docs/
├── DIRECTUS-SETUP.md # Directus Backend Setup ├── DIRECTUS-SETUP.md # Directus Backend Setup
├── DIRECTUS-SCHEMA.md # Collection-Strukturen & Permissions ├── DIRECTUS-SCHEMA.md # Collection-Strukturen & Permissions
├── MONETIZATION.md # Monetarisierung & Anti-Abuse ├── MONETIZATION.md # Monetarisierung & Anti-Abuse
── pow-server/ # PHP PoW-Captcha Server (pow.dgray.io) ── pow-server/ # PHP PoW-Captcha Server (pow.dgray.io)
└── og-proxy.php # Open Graph Meta-Tag Proxy (pow.dgray.io)
css/ css/
├── fonts.css # @font-face Definitionen (Inter, Space Grotesk) ├── fonts.css # @font-face Definitionen (Inter, Space Grotesk)
@@ -88,7 +89,8 @@ css/
└── components.css # UI-Komponenten (Buttons, Cards, etc.) └── components.css # UI-Komponenten (Buttons, Cards, etc.)
assets/ assets/
── fonts/ # Self-hosted Fonts (Inter, Space Grotesk) ── fonts/ # Self-hosted Fonts (Inter, Space Grotesk)
└── press/ # Press Kit (Logos, OG-Image, Brand Guidelines)
tests/ tests/
├── index.html # Test-Runner UI (im Browser öffnen) ├── index.html # Test-Runner UI (im Browser öffnen)

View File

@@ -67,7 +67,7 @@ dgray.io ermöglicht es Nutzern, Kleinanzeigen zu schalten und Waren/Dienstleist
### Services ### Services
- **Directus** Backend: `api.dgray.io` (Docker) - **Directus** Backend: `api.dgray.io` (Docker)
- **PoW Captcha + Payment Proxy**: `pow.dgray.io` (PHP, HMAC-signierte Challenges, BTCPay Proxy + Webhook) - **PoW Captcha + Payment Proxy**: `pow.dgray.io` (PHP, HMAC-signierte Challenges, BTCPay Proxy + Webhook, OG Meta Proxy)
- **BTCPay Server**: `pay.xmr.rocks` (Monero-Zahlungen, Trocador-Plugin) - **BTCPay Server**: `pay.xmr.rocks` (Monero-Zahlungen, Trocador-Plugin)
- **TweetNaCl**: Self-hosted in `js/vendor/` (E2E-Verschlüsselung) - **TweetNaCl**: Self-hosted in `js/vendor/` (E2E-Verschlüsselung)
@@ -219,6 +219,7 @@ dgray/
- [x] PoW Captcha (server-seitig via pow.dgray.io, HMAC-signiert) - [x] PoW Captcha (server-seitig via pow.dgray.io, HMAC-signiert)
- [x] TweetNaCl self-hosted (kein CDN) - [x] TweetNaCl self-hosted (kein CDN)
- [x] In-App Benachrichtigungen (Notifications-Service, Glocke mit Badge) - [x] In-App Benachrichtigungen (Notifications-Service, Glocke mit Badge)
- [x] Open Graph & X Card Meta-Tags (dynamisch pro Listing)
- [ ] Push-Benachrichtigungen (Web Push API) - [ ] Push-Benachrichtigungen (Web Push API)
### Phase 4: Payments ### Phase 4: Payments

View File

@@ -0,0 +1,112 @@
<?php
/**
* OG Meta Tag Proxy for Social Media Crawlers
*
* Setup: Nginx/Apache rewrite rule on dgray.io:
* If User-Agent matches crawler → proxy to this script
* Else → serve static index.html
*
* Example Nginx config:
* if ($http_user_agent ~* "Twitterbot|facebookexternalhit|TelegramBot|Discordbot|Slackbot|LinkedInBot|WhatsApp") {
* proxy_pass https://pow.dgray.io/og-proxy.php;
* }
*/
require __DIR__ . '/config.php';
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
// Extract listing ID from hash URL or query param
// Crawlers may receive: /listing/UUID or ?listing=UUID
$listingId = null;
if (preg_match('#/listing/([a-f0-9-]{36})#i', $requestUri, $matches)) {
$listingId = $matches[1];
}
if (!$listingId && isset($_GET['listing'])) {
$listingId = $_GET['listing'];
}
$siteUrl = 'https://dgray.io';
$defaultTitle = 'dgray.io Anonymous Classifieds with Monero';
$defaultDesc = 'Buy and sell anonymously with Monero. No KYC, no email, E2E encrypted chat.';
$defaultImage = $siteUrl . '/assets/press/og-image.png';
$title = $defaultTitle;
$description = $defaultDesc;
$image = $defaultImage;
$url = $siteUrl;
$type = 'website';
if ($listingId) {
$apiUrl = DIRECTUS_URL . '/items/listings/' . urlencode($listingId)
. '?fields=id,title,description,price,currency,images.directus_files_id.id';
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . DIRECTUS_TOKEN,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);
$listing = $data['data'] ?? null;
if ($listing) {
$title = htmlspecialchars($listing['title'] ?? '') . ' dgray.io';
$description = htmlspecialchars(mb_substr($listing['description'] ?? '', 0, 160));
$url = $siteUrl . '/#/listing/' . $listing['id'];
$type = 'product';
$imageId = $listing['images'][0]['directus_files_id']['id']
?? $listing['images'][0]['directus_files_id']
?? null;
if ($imageId) {
$image = DIRECTUS_URL . '/assets/' . $imageId . '?width=1200&height=630&fit=cover';
}
if (!empty($listing['price']) && !empty($listing['currency'])) {
$price = number_format((float)$listing['price'], 2);
$description .= " | {$price} {$listing['currency']}";
}
}
}
header('Content-Type: text/html; charset=utf-8');
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title><?= $title ?></title>
<meta name="description" content="<?= $description ?>">
<!-- Open Graph -->
<meta property="og:type" content="<?= $type ?>">
<meta property="og:site_name" content="dgray.io">
<meta property="og:title" content="<?= $title ?>">
<meta property="og:description" content="<?= $description ?>">
<meta property="og:url" content="<?= $url ?>">
<meta property="og:image" content="<?= $image ?>">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<!-- X (Twitter) Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<?= $title ?>">
<meta name="twitter:description" content="<?= $description ?>">
<meta name="twitter:image" content="<?= $image ?>">
<!-- Redirect real users to the actual page -->
<meta http-equiv="refresh" content="0;url=<?= $url ?>">
</head>
<body>
<p>Redirecting to <a href="<?= $url ?>"><?= $title ?></a></p>
</body>
</html>

View File

@@ -3,10 +3,29 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="dgray.io - deals in gray"> <meta name="description" content="Anonymer Marktplatz mit Monero-Bezahlung. Keine persönlichen Daten, E2E-verschlüsselter Chat.">
<meta name="theme-color" content="#555555"> <meta name="theme-color" content="#555555">
<title>dgray.io</title> <title>dgray.io Anonymous Classifieds with Monero</title>
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="dgray.io">
<meta property="og:title" content="dgray.io Anonymous Classifieds with Monero">
<meta property="og:description" content="Buy and sell anonymously with Monero. No KYC, no email, E2E encrypted chat.">
<meta property="og:url" content="https://dgray.io">
<meta property="og:image" content="https://dgray.io/assets/press/og-image.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:locale" content="de_DE">
<meta property="og:locale:alternate" content="en_US">
<meta property="og:locale:alternate" content="fr_FR">
<!-- X (Twitter) Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="dgray.io Anonymous Classifieds with Monero">
<meta name="twitter:description" content="Buy and sell anonymously with Monero. No KYC, no email, E2E encrypted chat.">
<meta name="twitter:image" content="https://dgray.io/assets/press/og-image.png">
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<link rel="icon" type="image/svg+xml" href="assets/icon-light.svg" media="(prefers-color-scheme: light)"> <link rel="icon" type="image/svg+xml" href="assets/icon-light.svg" media="(prefers-color-scheme: light)">

View File

@@ -32,6 +32,7 @@ class PageListing extends HTMLElement {
disconnectedCallback() { disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe() if (this.unsubscribe) this.unsubscribe()
window.removeEventListener('currency-changed', this.handleCurrencyChange) window.removeEventListener('currency-changed', this.handleCurrencyChange)
this.resetMetaTags()
} }
handleCurrencyChange() { handleCurrencyChange() {
@@ -67,6 +68,58 @@ class PageListing extends HTMLElement {
this.loading = false this.loading = false
this.render() this.render()
this.setupEventListeners() this.setupEventListeners()
this.updateMetaTags()
}
updateMetaTags() {
if (!this.listing) return
const title = `${this.listing.title} dgray.io`
const description = (this.listing.description || '').substring(0, 160)
const imageId = this.listing.images?.[0]?.directus_files_id?.id || this.listing.images?.[0]?.directus_files_id
const imageUrl = imageId ? directus.getFileUrl(imageId, { width: 1200, height: 630, fit: 'cover' }) : 'https://dgray.io/assets/press/og-image.png'
const url = `https://dgray.io/#/listing/${this.listing.id}`
document.title = title
this._setMeta('description', description)
this._setMeta('og:title', title, true)
this._setMeta('og:description', description, true)
this._setMeta('og:image', imageUrl, true)
this._setMeta('og:url', url, true)
this._setMeta('og:type', 'product', true)
this._setMeta('twitter:title', title)
this._setMeta('twitter:description', description)
this._setMeta('twitter:image', imageUrl)
}
resetMetaTags() {
const defaultTitle = 'dgray.io Anonymous Classifieds with Monero'
const defaultDesc = 'Buy and sell anonymously with Monero. No KYC, no email, E2E encrypted chat.'
const defaultImage = 'https://dgray.io/assets/press/og-image.png'
document.title = defaultTitle
this._setMeta('description', defaultDesc)
this._setMeta('og:title', defaultTitle, true)
this._setMeta('og:description', defaultDesc, true)
this._setMeta('og:image', defaultImage, true)
this._setMeta('og:url', 'https://dgray.io', true)
this._setMeta('og:type', 'website', true)
this._setMeta('twitter:title', defaultTitle)
this._setMeta('twitter:description', defaultDesc)
this._setMeta('twitter:image', defaultImage)
}
_setMeta(name, content, isProperty = false) {
const attr = isProperty ? 'property' : 'name'
let el = document.querySelector(`meta[${attr}="${name}"]`)
if (el) {
el.setAttribute('content', content)
} else {
el = document.createElement('meta')
el.setAttribute(attr, name)
el.setAttribute('content', content)
document.head.appendChild(el)
}
} }
async checkOwnership() { async checkOwnership() {