commit e37663e5661ac12b7edb2a52951d5bff98b9d1d3 Author: simonpipe Date: Thu May 28 08:13:15 2026 +0200 Upload files to "/" diff --git a/extension.ini b/extension.ini new file mode 100644 index 0000000..54bc1c0 --- /dev/null +++ b/extension.ini @@ -0,0 +1,14 @@ +# Datenstrom Yellow extension settings + +Extension: Mastodonapi +Version: 0.9.8 +Description: Import your toots into your blog. +Developer: Simon Rohr +Tag: feature +DownloadUrl: https://codeberg.org/simonpipe/yellow-mastodonapi/archive/main.zip +DocumentationUrl: https://codeberg.org/simonpipe/yellow-mastodonapi +DocumentationLanguage: en, de +Published: 2026-05-27 14:20:00 +Status: experimental +system/workers/mastodonapi.php: mastodonapi.php, create, update +system/workers/mastodonapi.status: mastodonapi.status, create, update, careful \ No newline at end of file diff --git a/externallinks.php b/externallinks.php new file mode 100644 index 0000000..6fd9d79 --- /dev/null +++ b/externallinks.php @@ -0,0 +1,296 @@ +yellow = $yellow; + $this->yellow->system->setDefault("mastodonapiInstanceUrl", "https://example.social"); + $this->yellow->system->setDefault("mastodonapiAccountId", ""); + $this->yellow->system->setDefault("mastodonapiAccessToken", ""); + $this->yellow->system->setDefault("mastodonapiUpdateInterval", "30"); + $this->yellow->system->setDefault("mastodonapiLimit", "20"); + $this->yellow->system->setDefault("mastodonapiHashtag", ""); + $this->yellow->system->setDefault("mastodonapiAuthor", "YellowUser"); + $this->yellow->system->setDefault("mastodonapiTag", "Fediverse"); + $this->yellow->system->setDefault("mastodonapiFolder", "content/2-blog/"); + $this->yellow->system->setDefault("mastodonapiMaxAge", "0"); + $this->yellow->system->setDefault("mastodonapiStatus", "public"); + } + + // Handle update + public function onUpdate($action) { + if ( $action=="mastodon" || $action=="daily" || $action=="install" ) { + $this->updateMastodonPosts(); + } + } + + // Handle command + public function onCommand($command, $text) { + if ( $command=="mastodonapi" ) { + echo "Mastodon Import gestartet...\n"; + $count = $this->updateMastodonPosts(); + if ( $count===false ) { + echo "Fehler: API nicht erreichbar, falsche ID oder Zugriff verweigert.\n"; + return 500; + } + echo "Erfolg: " . $count . " neue Posts verarbeitet.\n"; + return true; + } + return 0; + } + + // Handle periodic update + public function onTick() { + $interval = intval($this->yellow->system->get("mastodonapiUpdateInterval")) * 60; + $statusFile = $this->getStatusFile(); + $lastUpdate = file_exists($statusFile) ? intval(file_get_contents($statusFile)) : 0; + if ( time() - $lastUpdate >= $interval ) { + $this->updateMastodonPosts(); + } + } + + // Update Mastodon posts + public function updateMastodonPosts() { + $postsCreated = $this->processMastodonFeed(); + if ( $postsCreated!==false ) { + $statusFile = $this->getStatusFile(); + file_put_contents($statusFile, time()); + } + return $postsCreated; + } + + // Process Mastodon feed and create pages + private function processMastodonFeed() { + $instanceUrl = $this->yellow->system->get("mastodonapiInstanceUrl"); + $accountId = $this->yellow->system->get("mastodonapiAccountId"); + $accessToken = $this->yellow->system->get("mastodonapiAccessToken"); + $hashtag = ltrim($this->yellow->system->get("mastodonapiHashtag"), "#"); + $defaultAuthor = $this->yellow->system->get("mastodonapiAuthor"); + $defaultTag = $this->yellow->system->get("mastodonapiTag"); + $targetFolder = $this->yellow->system->get("mastodonapiFolder"); + $statusSetting = $this->yellow->system->get("mastodonapiStatus"); + $maxAgeHours = intval($this->yellow->system->get("mastodonapiMaxAge")); + $limit = intval($this->yellow->system->get("mastodonapiLimit")); + + if ( empty($instanceUrl) || empty($accountId) ) return false; + + $apiUrl = rtrim($instanceUrl, "/") . "/api/v1/accounts/" . $accountId . "/statuses?limit=" . $limit; + + $headers = array("User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) YellowMastodonapi/" . self::VERSION); + if ( !empty($accessToken) ) $headers[] = "Authorization: Bearer " . $accessToken; + + $ch = curl_init($apiUrl); + curl_setopt_array($ch, array( + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => 0 + )); + $json = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ( $json===false || $httpCode < 200 || $httpCode >= 300 ) return false; + $posts = json_decode($json, true); + if ( !is_array($posts) ) return false; + + echo "DEBUG: " . count($posts) . " Posts von API empfangen.\n"; + + $postsCreated = 0; + foreach ( $posts as $index => $post ) { + // Snac-Verschachtelung auflösen: Wenn ein echtes 'reblog'-Objekt von jemand anderem drinsteckt + if ( !empty($post["reblog"]) && is_array($post["reblog"]) ) { + // Nur überspringen, wenn es wirklich der Post eines ANDEREN Nutzers ist, den du geteilt hast + if ( !empty($post["reblog"]["account"]["id"]) && $post["reblog"]["account"]["id"] != $accountId ) { + echo "DEBUG [$index]: Übersprungen (Echter Reblog eines fremden Beitrags)\n"; + continue; + } + // Wenn es dein eigener ist, nutzen wir die inneren Daten + $post = $post["reblog"]; + } + + $contentHtml = $post["content"] ?? ""; + $createdAt = $post["created_at"] ?? ""; + $shortText = mb_substr(strip_tags($contentHtml), 0, 40) . "..."; + + // Reblogged-Flag nur werten, wenn es kein Eigen-Boost ist + if ( (isset($post["reblogged"]) && $post["reblogged"]===true) && empty($contentHtml) ) { + echo "DEBUG [$index]: Übersprungen (Leerer Boost): $shortText\n"; + continue; + } + + // REPLY CHECK (Snac sendet leere Strings oder null) + if ( !empty($post["in_reply_to_id"]) ) { + echo "DEBUG [$index]: Übersprungen (Antwort - Reply-ID: " . $post["in_reply_to_id"] . "): $shortText\n"; + continue; + } + + // ALTER CHECK + if ( $maxAgeHours > 0 ) { + if ( strtotime($createdAt) < ( time() - ( $maxAgeHours * 3600 ) ) ) { + echo "DEBUG [$index]: Übersprungen (Zu alt - Datum: $createdAt): $shortText\n"; + continue; + } + } + + // HASHTAG CHECK + $tags = array_column($post["tags"] ?? array(), "name"); + if ( !empty($hashtag) && !in_array(strtolower($hashtag), array_map("strtolower", $tags)) ) { + echo "DEBUG [$index]: Übersprungen (Hashtag #$hashtag fehlt): $shortText\n"; + continue; + } + + $title = $this->generateTitle($contentHtml); + $titleSlug = $this->generateTitleSlug($title); + $fileName = date("Y-m-d", strtotime($createdAt)) . "-" . $titleSlug . ".md"; + $fullPath = $this->yellow->system->get("coreServerDocument") . $targetFolder . $fileName; + + // EXISTENZ CHECK + if ( file_exists($fullPath) ) { + echo "DEBUG [$index]: Übersprungen (Datei existiert bereits: $fileName): $shortText\n"; + continue; + } + + if ( !is_dir(dirname($fullPath)) ) { + mkdir(dirname($fullPath), 0777, true); + chmod(dirname($fullPath), 0777); + } + + $imageFilenames = array(); + $imageAltTexts = array(); + foreach ( $post["media_attachments"] ?? array() as $media ) { + if ( ( $media["type"] ?? "" )==="image" ) { + $filename = $this->downloadAndSaveImage($media, $this->yellow->system->get("coreServerDocument") . "media/images/"); + if ( $filename ) { + $imageFilenames[] = $filename; + $imageAltTexts[] = $media["description"] ?? ""; + } + } + } + + $authorName = !empty($post["account"]["display_name"]) ? $post["account"]["display_name"] : $defaultAuthor; + $authorUrl = !empty($post["account"]["url"]) ? $post["account"]["url"] : "#"; + + $markdownContent = $this->generateMarkdown( + $title, $titleSlug, $createdAt, $contentHtml, + $authorName, $authorUrl, + $defaultAuthor, $defaultTag, $imageFilenames, $imageAltTexts, $statusSetting + ); + + file_put_contents($fullPath, $markdownContent); + chmod($fullPath, 0666); + echo "-> ERSTELLT [$index]: $fileName ($shortText)\n"; + $postsCreated++; + } + return $postsCreated; + } + + // Download and save image + private function downloadAndSaveImage($media, $dirPath) { + $imageUrl = $media['remote_url'] + ?? $media['url'] + ?? $media['preview_url'] + ?? ($media['meta']['original']['url'] ?? null) + ?? ($media['meta']['small']['url'] ?? null); + + if (empty($imageUrl)) return false; + + if (!is_dir($dirPath)) mkdir($dirPath, 0755, true); + + $fileName = basename(parse_url($imageUrl, PHP_URL_PATH)); + + if (!strpos($fileName, '.')) { + $fileName .= ".jpg"; + } + + $fullPath = $dirPath . $fileName; + + if (file_exists($fullPath) && filesize($fullPath) > 0) return $fileName; + + $ch = curl_init($imageUrl); + $headers = ["User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) YellowMastodonAPI"]; + + $accessToken = $this->yellow->system->get("mastodonapiAccessToken"); + if (!empty($accessToken)) { + $headers[] = "Authorization: Bearer " . $accessToken; + } + + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => 0 + ]); + + $imgData = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($imgData === false || $httpCode < 200 || $httpCode >= 300) { + return false; + } + + file_put_contents($fullPath, $imgData); + return $fileName; + } + + // Generate title from content + private function generateTitle($content) { + $text = trim(strip_tags($content)); + $text = preg_replace("/https?:\/\/[^\s]+|#\w+/u", "", $text); + $words = explode(" ", $text); + return trim(mb_substr(implode(" ", array_slice($words, 0, 7)), 0, 100, "UTF-8")) ?: "Mastodon Post"; + } + + // Generate slug from title + private function generateTitleSlug($title) { + $slug = mb_strtolower($title, "UTF-8"); + $slug = str_replace(array("ä","ö","ü","ß"," "), array("ae","oe","ue","ss","-"), $slug); + return trim(preg_replace("/[^a-z0-9-]+/", "", $slug), "-"); + } + + // Generate markdown content + private function generateMarkdown($title, $slug, $dateTime, $html, $origAuthor, $origUrl, $yellowAuthor, $yellowTag, $images, $alts, $status) { + $date = new DateTime($dateTime); + $pub = $date->format("Y-m-d H:i:s"); + $content = trim(html_entity_decode(strip_tags(str_replace(array("
", "

"), array("\n", "

\n"), $html)), ENT_QUOTES | ENT_HTML5, "UTF-8")); + $content = str_replace(array("\n", "#"), array("\n> ", "#"), $content); + $imageMarkdown = ""; + $featuredImage = $featuredImageAlt = ""; + foreach ( $images as $i => $filename ) { + $alt = isset($alts[$i]) ? htmlspecialchars($alts[$i], ENT_QUOTES) : ""; + $imageMarkdown .= "\n\"$alt\"/\n"; + if ( $i===0 ) { + $featuredImage = "Image: $filename\n"; + $featuredImageAlt = "ImageAlt: $alt\n"; + } + } + $authorSafe = str_replace("@", "@", $origAuthor); + return "---\n" . + "Title: $title\n" . + "TitleSlug: $slug\n" . + "Published: $pub\n" . + "Author: $yellowAuthor\n" . + "Layout: blog\n" . + "Tag: $yellowTag\n" . + "Status: $status\n" . + $featuredImage . $featuredImageAlt . + "---\n\n" . + $imageMarkdown . "\n" . + "> " . $content . "\n>\n" . + "> — $authorSafe"; + } + + // Return status file path + private function getStatusFile() { + return $this->yellow->system->get("coreWorkerDirectory") . "mastodonapi.status"; + } +} \ No newline at end of file