From 246a159f34fb63a38c7d70c9a7be39766c0621f5 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 10:56:08 -0300 Subject: [PATCH 1/7] feat(automations): multi-format RSS/Atom feeds with dynamic variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the RSS-2.0-only SimpleXML parser with SimplePie so the Fetch RSS node reads Atom 1.0 (YouTube, GitHub, The Verge…) and RSS 2.0 + namespace extensions (dc:, content:, media:, yt:, itunes:). Each item exposes stable cross-format aliases (title, link, date, content, author, …) plus every namespaced field flattened for use as {{ fetched.* }}. Add a feed-inspection endpoint that discovers a feed's real fields and feeds them into the editor's expression autocomplete. Allow {{ }} expressions in the feed URL via a ResolvableUrl rule. Parsing moves to a dedicated FeedParser service; the fetch keeps the SSRF guard and gains XXE-safe parsing. --- .../Automation/Node/RunFetchRssNode.php | 45 +++-- .../Controllers/App/AutomationController.php | 43 +++++ .../App/Automations/InspectFeedRequest.php | 25 +++ .../Automations/UpdateAutomationRequest.php | 6 +- .../App/Automation/FeedInspectionResource.php | 69 +++++++ app/Rules/ResolvableUrl.php | 36 ++++ app/Services/Automation/FeedParser.php | 179 ++++++++++++++++++ composer.json | 1 + composer.lock | 83 +++++++- lang/en/automations.php | 14 +- lang/es/automations.php | 14 +- lang/pt-BR/automations.php | 14 +- .../automations/config/FetchRssNodeConfig.vue | 63 +++++- .../composables/useExpressionCompletions.ts | 22 ++- resources/js/pages/automations/Form.vue | 1 + routes/app.php | 1 + .../Feature/Automation/FeedInspectionTest.php | 138 ++++++++++++++ .../Automation/Node/FetchRssNodeTest.php | 28 ++- tests/Unit/Automation/FeedParserTest.php | 81 ++++++++ tests/fixtures/feeds/podcast.xml | 20 ++ tests/fixtures/feeds/rss_namespaces.xml | 20 ++ tests/fixtures/feeds/youtube_atom.xml | 36 ++++ 22 files changed, 904 insertions(+), 35 deletions(-) create mode 100644 app/Http/Requests/App/Automations/InspectFeedRequest.php create mode 100644 app/Http/Resources/App/Automation/FeedInspectionResource.php create mode 100644 app/Rules/ResolvableUrl.php create mode 100644 app/Services/Automation/FeedParser.php create mode 100644 tests/Feature/Automation/FeedInspectionTest.php create mode 100644 tests/Unit/Automation/FeedParserTest.php create mode 100644 tests/fixtures/feeds/podcast.xml create mode 100644 tests/fixtures/feeds/rss_namespaces.xml create mode 100644 tests/fixtures/feeds/youtube_atom.xml diff --git a/app/Actions/Automation/Node/RunFetchRssNode.php b/app/Actions/Automation/Node/RunFetchRssNode.php index cc0b02b3..a8394ee7 100644 --- a/app/Actions/Automation/Node/RunFetchRssNode.php +++ b/app/Actions/Automation/Node/RunFetchRssNode.php @@ -10,11 +10,11 @@ use App\Models\AutomationNodeState; use App\Models\AutomationRun; use App\Services\Automation\ExpressionResolver; +use App\Services\Automation\FeedParser; use App\Services\Brand\SafeHttpFetcher; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Http; use RuntimeException; -use SimpleXMLElement; use Throwable; /** @@ -40,6 +40,7 @@ public function __construct( private ExpressionResolver $resolver, private SafeHttpFetcher $safeHttp, private AdvanceAutomationRun $advance, + private FeedParser $parser, ) {} public function __invoke(AutomationRun $run, array $config): NodeRunResult @@ -65,9 +66,9 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult return NodeRunResult::failed(__('automations.errors.fetch_rss_request_failed'), ['status' => $response->status()]); } - try { - $xml = new SimpleXMLElement($response->body()); - } catch (Throwable) { + $items = $this->parser->parse($response->body()); + + if ($items === null) { return NodeRunResult::failed(__('automations.errors.fetch_rss_malformed')); } @@ -83,7 +84,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult ? CarbonImmutable::createFromTimestamp(0) : $this->parseWatermark(data_get($state->data, 'last_item_date')); - [$newItems, $newestSeen] = $this->collectNewItems($xml, $watermark); + [$newItems, $newestSeen] = $this->collectNewItems($items, $watermark); if ($state !== null && $newestSeen !== null) { $state->update(['data' => array_merge($state->data ?? [], [ @@ -107,46 +108,44 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult ]); } - private function collectNewItems(SimpleXMLElement $xml, CarbonImmutable $watermark): array + /** + * @param list> $parsed Normalized items from FeedParser. + * @return array{0: list>, 1: ?CarbonImmutable} + */ + private function collectNewItems(array $parsed, CarbonImmutable $watermark): array { $items = []; $newestSeen = null; - foreach ($xml->channel->item ?? [] as $item) { - $key = (string) ($item->guid ?? $item->link); + foreach ($parsed as $item) { + $key = (string) data_get($item, 'key', ''); if ($key === '') { continue; } - $pubDate = $this->parsePubDate((string) $item->pubDate); - if ($pubDate === null) { + $date = $this->parsePubDate((string) data_get($item, 'date', '')); + if ($date === null) { continue; } - if ($newestSeen === null || $pubDate->greaterThan($newestSeen)) { - $newestSeen = $pubDate; + if ($newestSeen === null || $date->greaterThan($newestSeen)) { + $newestSeen = $date; } - if (! $pubDate->greaterThan($watermark)) { + if (! $date->greaterThan($watermark)) { continue; } - $items[] = [ - '_pubDate' => $pubDate, - 'key' => $key, - 'title' => (string) $item->title, - 'link' => (string) $item->link, - 'description' => (string) $item->description, - 'pubDate' => (string) $item->pubDate, - ]; + $item['_sort'] = $date->getTimestamp(); + $items[] = $item; } // Process oldest-first so siblings inherit a stable order matching feed chronology. - usort($items, fn ($a, $b) => $a['_pubDate']->getTimestamp() <=> $b['_pubDate']->getTimestamp()); + usort($items, fn ($a, $b) => $a['_sort'] <=> $b['_sort']); // Drop the internal sort key — downstream nodes shouldn't see it. $items = array_map(function (array $item): array { - unset($item['_pubDate']); + unset($item['_sort']); return $item; }, $items); diff --git a/app/Http/Controllers/App/AutomationController.php b/app/Http/Controllers/App/AutomationController.php index cfd6b898..b31c74c4 100644 --- a/app/Http/Controllers/App/AutomationController.php +++ b/app/Http/Controllers/App/AutomationController.php @@ -17,11 +17,13 @@ use App\Actions\Automation\Run\TestAutomation; use App\Http\Controllers\Controller; use App\Http\Requests\App\Automations\ActivateAutomationRequest; +use App\Http\Requests\App\Automations\InspectFeedRequest; use App\Http\Requests\App\Automations\PauseAutomationRequest; use App\Http\Requests\App\Automations\RetryRunRequest; use App\Http\Requests\App\Automations\StoreAutomationRequest; use App\Http\Requests\App\Automations\TestAutomationRequest; use App\Http\Requests\App\Automations\UpdateAutomationRequest; +use App\Http\Resources\App\Automation\FeedInspectionResource; use App\Http\Resources\App\PlatformConfigResource; use App\Http\Resources\App\SocialAccountResource; use App\Http\Resources\AutomationInvocationResource; @@ -31,11 +33,17 @@ use App\Models\Automation; use App\Models\AutomationNodeRun; use App\Models\AutomationRun; +use App\Services\Automation\ExpressionResolver; +use App\Services\Automation\FeedParser; +use App\Services\Brand\SafeHttpFetcher; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Response as HttpResponse; +use Illuminate\Support\Facades\Http; use Inertia\Inertia; use Inertia\Response; +use RuntimeException; +use Symfony\Component\HttpFoundation\Response as SymfonyResponse; class AutomationController extends Controller { @@ -203,6 +211,41 @@ public function test(TestAutomationRequest $request, Automation $automation, Tes return response()->json(['run_id' => $run->id]); } + public function inspectFeed( + InspectFeedRequest $request, + Automation $automation, + ExpressionResolver $resolver, + SafeHttpFetcher $safeHttp, + FeedParser $parser, + ): JsonResponse|FeedInspectionResource { + $this->authorize('update', $automation); + + $feedUrl = $resolver->resolve( + (string) $request->validated('feed_url'), + ['variables' => $automation->resolvedVariables()], + ); + + try { + $safeHttp->guardAgainstSsrf($feedUrl); + } catch (RuntimeException) { + return response()->json(['message' => __('automations.errors.url_not_allowed')], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY); + } + + $response = Http::get($feedUrl); + + if (! $response->successful()) { + return response()->json(['message' => __('automations.errors.fetch_rss_request_failed')], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY); + } + + $items = $parser->parse($response->body()); + + if ($items === null) { + return response()->json(['message' => __('automations.errors.fetch_rss_malformed')], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY); + } + + return new FeedInspectionResource($items[0] ?? []); + } + public function showRun(Automation $automation, AutomationRun $run): JsonResponse { $this->authorize('view', $automation); diff --git a/app/Http/Requests/App/Automations/InspectFeedRequest.php b/app/Http/Requests/App/Automations/InspectFeedRequest.php new file mode 100644 index 00000000..e3d878e5 --- /dev/null +++ b/app/Http/Requests/App/Automations/InspectFeedRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'feed_url' => ['required', 'string', 'max:2048'], + ]; + } +} diff --git a/app/Http/Requests/App/Automations/UpdateAutomationRequest.php b/app/Http/Requests/App/Automations/UpdateAutomationRequest.php index bae5eb20..ed94fa4b 100644 --- a/app/Http/Requests/App/Automations/UpdateAutomationRequest.php +++ b/app/Http/Requests/App/Automations/UpdateAutomationRequest.php @@ -12,6 +12,7 @@ use App\Enums\Automation\Publish\Mode as PublishMode; use App\Enums\Automation\ScheduleField; use App\Enums\Automation\Trigger\Type as TriggerType; +use App\Rules\ResolvableUrl; use App\Services\Automation\AutomationConfigValidator; use App\Services\Automation\GenerateNodeValidator; use Illuminate\Contracts\Validation\Validator; @@ -129,7 +130,10 @@ private function dataRulesForNodeType(?string $type, int $i): array 'schedule_timezone' => ['sometimes', 'string', 'timezone'], ], NodeType::FetchRss->value => [ - 'feed_url' => ['required', 'url'], + 'feed_url' => ['required', new ResolvableUrl], + 'discovered_fields' => ['sometimes', 'array'], + 'discovered_fields.*.path' => ['required', 'string'], + 'discovered_fields.*.sample' => ['nullable', 'string'], ], NodeType::HttpRequest->value => [ 'url' => ['required', 'url'], diff --git a/app/Http/Resources/App/Automation/FeedInspectionResource.php b/app/Http/Resources/App/Automation/FeedInspectionResource.php new file mode 100644 index 00000000..7aa2aed0 --- /dev/null +++ b/app/Http/Resources/App/Automation/FeedInspectionResource.php @@ -0,0 +1,69 @@ + $resource The first normalized feed item. + */ +class FeedInspectionResource extends JsonResource +{ + private const SAMPLE_MAX_LENGTH = 120; + + /** + * @return array{fields: list} + */ + public function toArray(Request $request): array + { + return [ + 'fields' => $this->flatten((array) $this->resource), + ]; + } + + /** + * @param array $item + * @return list + */ + private function flatten(array $item, string $prefix = 'fetched'): array + { + $fields = []; + + foreach ($item as $key => $value) { + $path = "{$prefix}.{$key}"; + + if (is_array($value) && $this->isAssoc($value)) { + $fields = array_merge($fields, $this->flatten($value, $path)); + + continue; + } + + $fields[] = ['path' => $path, 'sample' => $this->sample($value)]; + } + + return $fields; + } + + /** + * @param array $value + */ + private function isAssoc(array $value): bool + { + return $value !== [] && ! array_is_list($value); + } + + private function sample(mixed $value): string + { + $text = is_array($value) + ? (json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '') + : (string) $value; + + return str($text)->squish()->limit(self::SAMPLE_MAX_LENGTH)->value(); + } +} diff --git a/app/Rules/ResolvableUrl.php b/app/Rules/ResolvableUrl.php new file mode 100644 index 00000000..af5db97d --- /dev/null +++ b/app/Rules/ResolvableUrl.php @@ -0,0 +1,36 @@ +translate(); + + return; + } + + $candidate = preg_replace('/\{\{\s*[\w.]+\s*\}\}/', 'placeholder', $value); + + if (filter_var($candidate, FILTER_VALIDATE_URL) === false) { + $fail('validation.url')->translate(); + } + } +} diff --git a/app/Services/Automation/FeedParser.php b/app/Services/Automation/FeedParser.php new file mode 100644 index 00000000..52bc9ae6 --- /dev/null +++ b/app/Services/Automation/FeedParser.php @@ -0,0 +1,179 @@ + }}`. + * + * Aliases win on key collisions. The HTTP fetch and SSRF guard stay in the caller; + * the body is handed to SimplePie via `set_raw_data()` so the SSRF guard isn't bypassed. + */ +class FeedParser +{ + /** + * Namespace URI → short prefix for the raw layer. + */ + private const NS_PREFIX = [ + 'http://search.yahoo.com/mrss/' => 'media', + 'http://www.youtube.com/xml/schemas/2015' => 'yt', + 'http://purl.org/dc/elements/1.1/' => 'dc', + 'http://purl.org/dc/terms/' => 'dc', + 'http://purl.org/rss/1.0/modules/content/' => 'content', + 'http://www.itunes.com/dtds/podcast-1.0.dtd' => 'itunes', + 'https://podcastindex.org/namespace/1.0' => 'podcast', + 'http://purl.org/rss/1.0/modules/slash/' => 'slash', + 'http://wellformedweb.org/CommentAPI/' => 'wfw', + 'http://www.georss.org/georss' => 'georss', + ]; + + /** + * Namespaces treated as the feed "core" — their tags get no prefix, so RSS and + * Atom land on the same raw key names (and are then overridden by aliases). + */ + private const NS_CORE = [ + '', + 'http://www.w3.org/2005/Atom', + 'http://purl.org/rss/1.0/', + 'http://backend.userland.com/rss2', + 'http://my.netscape.com/rdf/simple/0.9/', + ]; + + /** + * @return list>|null Null when the body is not a valid feed. + */ + public function parse(string $body): ?array + { + $feed = new SimplePie; + $feed->enable_cache(false); + $feed->set_raw_data($body); + + if (! @$feed->init()) { + return null; + } + + return array_map(fn (Item $item): array => $this->normalize($item), $feed->get_items()); + } + + /** + * @return array + */ + private function normalize(Item $item): array + { + $enclosure = $item->get_enclosure(); + + $aliases = [ + 'key' => $item->get_id(false, false) ?: $item->get_permalink(), + 'title' => $item->get_title(), + 'link' => $item->get_permalink(), + 'date' => $item->get_date('c'), + 'pubDate' => $item->get_date('c'), + 'content' => $item->get_content(), + 'description' => $item->get_description(), + 'author' => $item->get_author()?->get_name(), + 'id' => $item->get_id(false, false), + 'image' => $enclosure?->get_thumbnail() ?: null, + 'categories' => array_values(array_filter(array_map( + fn ($category) => $category->get_label(), + $item->get_categories() ?? [], + ))), + 'enclosure' => $enclosure === null ? null : array_filter([ + 'url' => $enclosure->get_link(), + 'type' => $enclosure->get_type(), + 'length' => $enclosure->get_length(), + ], fn ($value) => $value !== null), + ]; + + // Aliases win on collision, so merge them over the raw layer. + return array_merge($this->rawFields($item), $aliases); + } + + /** + * @return array + */ + private function rawFields(Item $item): array + { + return $this->flattenChildren((array) data_get($item->data, 'child', [])); + } + + /** + * @param array $children Namespace-URI keyed tag map. + * @return array + */ + private function flattenChildren(array $children): array + { + $out = []; + + foreach ($children as $namespace => $tags) { + $prefix = $this->prefixFor((string) $namespace); + + foreach ($tags as $tag => $nodes) { + $key = $prefix === '' ? (string) $tag : "{$prefix}_{$tag}"; + $values = array_map(fn ($node) => $this->flattenNode((array) $node), $nodes); + $out[$key] = count($values) === 1 ? $values[0] : $values; + } + } + + return $out; + } + + /** + * @param array $node + */ + private function flattenNode(array $node): mixed + { + $attribs = $this->attributes($node); + $children = (array) data_get($node, 'child', []); + + if ($children !== []) { + return array_merge($this->flattenChildren($children), $attribs); + } + + $text = trim((string) data_get($node, 'data', '')); + + return match (true) { + $text !== '' && $attribs === [] => $text, + $text === '' && $attribs !== [] => $attribs, + $text !== '' && $attribs !== [] => array_merge(['_text' => $text], $attribs), + default => $text, + }; + } + + /** + * @param array $node + * @return array + */ + private function attributes(array $node): array + { + $out = []; + + foreach ((array) data_get($node, 'attribs', []) as $attrs) { + foreach ((array) $attrs as $name => $value) { + $out[(string) $name] = (string) $value; + } + } + + return $out; + } + + private function prefixFor(string $namespace): string + { + if (in_array($namespace, self::NS_CORE, true)) { + return ''; + } + + return self::NS_PREFIX[$namespace] ?? ''; + } +} diff --git a/composer.json b/composer.json index 831fcd1f..644ed3ef 100644 --- a/composer.json +++ b/composer.json @@ -56,6 +56,7 @@ "posthog/posthog-php": "^4.1", "predis/predis": "^3.3", "sendkit/sendkit-laravel": "^1.1", + "simplepie/simplepie": "^1.9", "socialiteproviders/facebook": "^4.1", "socialiteproviders/instagram": "^5.1", "socialiteproviders/linkedin": "^5.0", diff --git a/composer.lock b/composer.lock index 4f28b729..4b5bb064 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4d71722b650e74b41e1943d3ae9c814f", + "content-hash": "19849eb01cb4d8f93733666b53444c5a", "packages": [ { "name": "aws/aws-crt-php", @@ -7402,6 +7402,87 @@ }, "time": "2026-03-20T00:53:21+00:00" }, + { + "name": "simplepie/simplepie", + "version": "1.9.0", + "source": { + "type": "git", + "url": "https://github.com/simplepie/simplepie.git", + "reference": "76cccb1b2c5dcaf44f304c925ab30c0f48643992" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simplepie/simplepie/zipball/76cccb1b2c5dcaf44f304c925ab30c0f48643992", + "reference": "76cccb1b2c5dcaf44f304c925ab30c0f48643992", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "php": ">=7.2.0" + }, + "require-dev": { + "donatj/mock-webserver": "^2.7", + "friendsofphp/php-cs-fixer": "^2.19 || ^3.8", + "mf2/mf2": "^0.5.0", + "phpstan/phpstan": "~1.12.2", + "phpunit/phpunit": "^8 || ^9 || ^10", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/simple-cache": "^1 || ^2 || ^3" + }, + "suggest": { + "ext-curl": "", + "ext-iconv": "", + "ext-intl": "", + "ext-mbstring": "", + "mf2/mf2": "Microformat module that allows for parsing HTML for microformats" + }, + "type": "library", + "autoload": { + "psr-0": { + "SimplePie": "library" + }, + "psr-4": { + "SimplePie\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Ryan Parman", + "homepage": "http://ryanparman.com/", + "role": "Creator, alumnus developer" + }, + { + "name": "Sam Sneddon", + "homepage": "https://gsnedders.com/", + "role": "Alumnus developer" + }, + { + "name": "Ryan McCue", + "email": "me@ryanmccue.info", + "homepage": "http://ryanmccue.info/", + "role": "Developer" + } + ], + "description": "A simple Atom/RSS parsing library for PHP", + "homepage": "http://simplepie.org/", + "keywords": [ + "atom", + "feeds", + "rss" + ], + "support": { + "issues": "https://github.com/simplepie/simplepie/issues", + "source": "https://github.com/simplepie/simplepie/tree/1.9.0" + }, + "time": "2025-09-12T06:34:27+00:00" + }, { "name": "socialiteproviders/facebook", "version": "4.1.0", diff --git a/lang/en/automations.php b/lang/en/automations.php index 92089863..308f748e 100644 --- a/lang/en/automations.php +++ b/lang/en/automations.php @@ -144,7 +144,13 @@ 'trigger_post_published_at' => 'When the post was published', 'fetched_title' => 'Fetched item title', 'fetched_link' => 'Fetched item link', - 'fetched_description' => 'Fetched item description', + 'fetched_date' => 'Fetched item publish date', + 'fetched_content' => 'Fetched item full content', + 'fetched_description' => 'Fetched item summary', + 'fetched_author' => 'Fetched item author', + 'fetched_image' => 'Fetched item image URL', + 'fetched_categories' => 'Fetched item categories', + 'fetched_enclosure' => 'Fetched item media (audio/video/file)', 'fetched_pubdate' => 'Fetched item publish date', 'fetched_http' => 'Fetched HTTP item (append a field)', 'generated_content' => 'AI-generated post content', @@ -330,6 +336,12 @@ 'fetch_rss' => [ 'feed_url' => 'Feed URL', 'feed_url_hint' => 'On first run, the watermark is set to "now" so historical items don\'t flood downstream nodes. Subsequent runs only see items newer than the previous poll.', + 'inspect' => 'Inspect feed', + 'inspecting' => 'Inspecting…', + 'inspect_hint' => 'Fetch a sample to discover the available fields for use in downstream nodes.', + 'inspect_error' => 'Could not read this feed. Check the URL and try again.', + 'discovered_fields' => 'Available fields', + 'discovered_empty' => 'No fields found in the latest item.', ], 'http_request' => [ 'url' => 'URL', diff --git a/lang/es/automations.php b/lang/es/automations.php index a4284c34..0371e776 100644 --- a/lang/es/automations.php +++ b/lang/es/automations.php @@ -144,7 +144,13 @@ 'trigger_post_published_at' => 'Cuándo se publicó el post', 'fetched_title' => 'Título del ítem obtenido', 'fetched_link' => 'Enlace del ítem obtenido', - 'fetched_description' => 'Descripción del ítem obtenido', + 'fetched_date' => 'Fecha de publicación del ítem obtenido', + 'fetched_content' => 'Contenido completo del ítem obtenido', + 'fetched_description' => 'Resumen del ítem obtenido', + 'fetched_author' => 'Autor del ítem obtenido', + 'fetched_image' => 'URL de la imagen del ítem obtenido', + 'fetched_categories' => 'Categorías del ítem obtenido', + 'fetched_enclosure' => 'Multimedia del ítem (audio/vídeo/archivo)', 'fetched_pubdate' => 'Fecha de publicación del ítem obtenido', 'fetched_http' => 'Ítem HTTP obtenido (añade un campo)', 'generated_content' => 'Contenido del post generado por IA', @@ -330,6 +336,12 @@ 'fetch_rss' => [ 'feed_url' => 'URL del feed', 'feed_url_hint' => 'En la primera ejecución, el watermark se fija en "ahora" para no inundar los siguientes nodos con ítems históricos. Ejecuciones siguientes solo ven ítems nuevos.', + 'inspect' => 'Inspeccionar feed', + 'inspecting' => 'Inspeccionando…', + 'inspect_hint' => 'Obtén una muestra para descubrir los campos disponibles para usar en los siguientes nodos.', + 'inspect_error' => 'No se pudo leer este feed. Revisa la URL e inténtalo de nuevo.', + 'discovered_fields' => 'Campos disponibles', + 'discovered_empty' => 'No se encontraron campos en el último ítem.', ], 'http_request' => [ 'url' => 'URL', diff --git a/lang/pt-BR/automations.php b/lang/pt-BR/automations.php index 605cba9d..d937d8f0 100644 --- a/lang/pt-BR/automations.php +++ b/lang/pt-BR/automations.php @@ -144,7 +144,13 @@ 'trigger_post_published_at' => 'Quando o post foi publicado', 'fetched_title' => 'Título do item buscado', 'fetched_link' => 'Link do item buscado', - 'fetched_description' => 'Descrição do item buscado', + 'fetched_date' => 'Data de publicação do item buscado', + 'fetched_content' => 'Conteúdo completo do item buscado', + 'fetched_description' => 'Resumo do item buscado', + 'fetched_author' => 'Autor do item buscado', + 'fetched_image' => 'URL da imagem do item buscado', + 'fetched_categories' => 'Categorias do item buscado', + 'fetched_enclosure' => 'Mídia do item (áudio/vídeo/arquivo)', 'fetched_pubdate' => 'Data de publicação do item buscado', 'fetched_http' => 'Item HTTP buscado (adicione um campo)', 'generated_content' => 'Conteúdo do post gerado por IA', @@ -330,6 +336,12 @@ 'fetch_rss' => [ 'feed_url' => 'URL do feed', 'feed_url_hint' => 'Na primeira execução, o watermark é setado pra "agora" pra não inundar os próximos nós com items históricos. Execuções seguintes só veem items novos.', + 'inspect' => 'Inspecionar feed', + 'inspecting' => 'Inspecionando…', + 'inspect_hint' => 'Busca uma amostra pra descobrir os campos disponíveis pra usar nos próximos nós.', + 'inspect_error' => 'Não foi possível ler esse feed. Confira a URL e tente de novo.', + 'discovered_fields' => 'Campos disponíveis', + 'discovered_empty' => 'Nenhum campo encontrado no último item.', ], 'http_request' => [ 'url' => 'URL', diff --git a/resources/js/components/automations/config/FetchRssNodeConfig.vue b/resources/js/components/automations/config/FetchRssNodeConfig.vue index e38e79b5..74249ae5 100644 --- a/resources/js/components/automations/config/FetchRssNodeConfig.vue +++ b/resources/js/components/automations/config/FetchRssNodeConfig.vue @@ -1,12 +1,23 @@ diff --git a/resources/js/composables/useExpressionCompletions.ts b/resources/js/composables/useExpressionCompletions.ts index 88c5e28e..1fac1b8a 100644 --- a/resources/js/composables/useExpressionCompletions.ts +++ b/resources/js/composables/useExpressionCompletions.ts @@ -43,13 +43,29 @@ const providedBy = (node: GraphNode): ExpressionSuggestion[] => { } return [suggestion('trigger.event', 'trigger_event'), suggestion('trigger.fired_at', 'trigger_fired_at')]; } - case 'fetch_rss': - return [ + case 'fetch_rss': { + const aliases = [ suggestion('fetched.title', 'fetched_title'), suggestion('fetched.link', 'fetched_link'), + suggestion('fetched.date', 'fetched_date'), + suggestion('fetched.content', 'fetched_content'), suggestion('fetched.description', 'fetched_description'), - suggestion('fetched.pubDate', 'fetched_pubdate'), + suggestion('fetched.author', 'fetched_author'), + suggestion('fetched.image', 'fetched_image'), + suggestion('fetched.categories', 'fetched_categories'), + suggestion('fetched.enclosure', 'fetched_enclosure'), ]; + // Fields discovered by inspecting the real feed — the dynamic raw layer + // (yt_videoId, media_group.*, dc_creator, …) that aliases can't enumerate. + const discovered = Array.isArray(node.data?.discovered_fields) + ? (node.data.discovered_fields as Array<{ path: string; sample?: string }>).map( + (field): ExpressionSuggestion => ({ label: field.path, info: field.sample ?? '' }), + ) + : []; + const known = new Set(aliases.map((alias) => alias.label)); + + return [...aliases, ...discovered.filter((field) => !known.has(field.label))]; + } case 'http_request': return [suggestion('fetched', 'fetched_http')]; case 'generate': diff --git a/resources/js/pages/automations/Form.vue b/resources/js/pages/automations/Form.vue index 891ec93f..b3afe367 100644 --- a/resources/js/pages/automations/Form.vue +++ b/resources/js/pages/automations/Form.vue @@ -137,6 +137,7 @@ provide( 'automationExpressionCompletions', computed(() => buildExpressionCatalog(selectedNodeId.value, nodes.value, edges.value, variables.value)), ); +provide('automationId', props.automation.id); // Toggled by an expandable CodeEditor (see CodeEditor.vue) to slide out the // side-by-side editing panel, pushing the sidebar left to make room. diff --git a/routes/app.php b/routes/app.php index 403d9a60..39affce2 100644 --- a/routes/app.php +++ b/routes/app.php @@ -224,6 +224,7 @@ Route::post('automations/{automation}/pause', [AutomationController::class, 'pause'])->name('app.automations.pause'); Route::post('automations/{automation}/runs/{run}/retry', [AutomationController::class, 'retryRun'])->name('app.automations.runs.retry'); Route::post('automations/{automation}/test', [AutomationController::class, 'test'])->name('app.automations.test'); + Route::post('automations/{automation}/feed/inspect', [AutomationController::class, 'inspectFeed'])->name('app.automations.feed.inspect'); Route::get('automations/{automation}/runs/{run}', [AutomationController::class, 'showRun'])->name('app.automations.runs.show'); // API Keys diff --git a/tests/Feature/Automation/FeedInspectionTest.php b/tests/Feature/Automation/FeedInspectionTest.php new file mode 100644 index 00000000..7d31baed --- /dev/null +++ b/tests/Feature/Automation/FeedInspectionTest.php @@ -0,0 +1,138 @@ +workspace = Workspace::factory()->create(); + $this->user = User::factory()->create(['current_workspace_id' => $this->workspace->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]); + $this->user->refresh(); +}); + +it('returns the discovered fields for a feed', function () { + Http::fake(['1.1.1.1/*' => Http::response(file_get_contents(base_path('tests/fixtures/feeds/youtube_atom.xml')), 200)]); + + $automation = Automation::factory()->for($this->workspace)->create(); + + $response = $this->actingAs($this->user) + ->postJson(route('app.automations.feed.inspect', $automation->id), [ + 'feed_url' => 'https://1.1.1.1/feed.xml', + ]); + + $response->assertOk(); + + $paths = collect($response->json('fields'))->pluck('path'); + expect($paths)->toContain('fetched.title') + ->toContain('fetched.link') + ->toContain('fetched.yt_videoId') + ->toContain('fetched.media_group.media_thumbnail.url'); +}); + +it('resolves workspace variables in the feed url before fetching', function () { + Http::fake(['1.1.1.1/*' => Http::response(file_get_contents(base_path('tests/fixtures/feeds/youtube_atom.xml')), 200)]); + + $automation = Automation::factory()->for($this->workspace)->create([ + 'variables' => [['key' => 'HOST', 'value' => '1.1.1.1']], + ]); + + $this->actingAs($this->user) + ->postJson(route('app.automations.feed.inspect', $automation->id), [ + 'feed_url' => 'https://{{ variables.HOST }}/feed.xml', + ]) + ->assertOk(); + + Http::assertSent(fn ($request) => str_contains($request->url(), '1.1.1.1/feed.xml')); +}); + +it('returns 422 for a malformed feed', function () { + Http::fake(['1.1.1.1/*' => Http::response('not a feed', 200)]); + + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->postJson(route('app.automations.feed.inspect', $automation->id), [ + 'feed_url' => 'https://1.1.1.1/feed.xml', + ]) + ->assertStatus(422); +}); + +it('validates that feed_url is required', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->postJson(route('app.automations.feed.inspect', $automation->id), []) + ->assertStatus(422) + ->assertJsonValidationErrorFor('feed_url'); +}); + +it('persists discovered_fields stored on a fetch_rss node through an update', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->put(route('app.automations.update', $automation->id), [ + 'nodes' => [[ + 'id' => 'fetch_1', + 'type' => 'fetch_rss', + 'position' => ['x' => 0, 'y' => 0], + 'data' => [ + 'feed_url' => 'https://example.com/feed.xml', + 'discovered_fields' => [ + ['path' => 'fetched.yt_videoId', 'sample' => 'abc123'], + ], + ], + ]], + ]) + ->assertRedirect(); + + $node = collect($automation->fresh()->nodes)->firstWhere('id', 'fetch_1'); + expect(data_get($node, 'data.discovered_fields.0.path'))->toBe('fetched.yt_videoId'); +}); + +it('accepts a feed_url containing a workflow-variable expression', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->put(route('app.automations.update', $automation->id), [ + 'nodes' => [[ + 'id' => 'fetch_1', + 'type' => 'fetch_rss', + 'position' => ['x' => 0, 'y' => 0], + 'data' => ['feed_url' => 'https://www.youtube.com/feeds/videos.xml?channel_id={{ variables.CHANNEL_ID }}'], + ]], + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); +}); + +it('rejects a feed_url that is not a URL even after stripping expressions', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->put(route('app.automations.update', $automation->id), [ + 'nodes' => [[ + 'id' => 'fetch_1', + 'type' => 'fetch_rss', + 'position' => ['x' => 0, 'y' => 0], + 'data' => ['feed_url' => 'not a url {{ variables.X }}'], + ]], + ]) + ->assertSessionHasErrors('nodes.0.data.feed_url'); +}); + +it('forbids inspecting a feed for an automation in another workspace', function () { + Http::fake(['1.1.1.1/*' => Http::response('', 200)]); + + $automation = Automation::factory()->for(Workspace::factory()->create())->create(); + + $this->actingAs($this->user) + ->postJson(route('app.automations.feed.inspect', $automation->id), [ + 'feed_url' => 'https://1.1.1.1/feed.xml', + ]) + ->assertForbidden(); +}); diff --git a/tests/Feature/Automation/Node/FetchRssNodeTest.php b/tests/Feature/Automation/Node/FetchRssNodeTest.php index 48ae91f2..f915098e 100644 --- a/tests/Feature/Automation/Node/FetchRssNodeTest.php +++ b/tests/Feature/Automation/Node/FetchRssNodeTest.php @@ -28,7 +28,7 @@ Olderhttps://1.1.1.1/aaSat, 01 Feb 2025 12:00:00 +0000 Newer1https://1.1.1.1/bbSun, 01 Jun 2025 12:00:00 +0000 - Newer2https://1.1.1.1/ccMon, 15 Jun 2025 12:00:00 +0000 + Newer2https://1.1.1.1/ccSun, 15 Jun 2025 12:00:00 +0000 XML; @@ -256,7 +256,31 @@ $state = AutomationNodeState::for($automation->id, 'fetch_1'); expect(CarbonImmutable::parse($state->data['last_item_date'])->toIso8601String()) - ->toBe(CarbonImmutable::parse('Mon, 15 Jun 2025 12:00:00 +0000')->toIso8601String()); + ->toBe(CarbonImmutable::parse('Sun, 15 Jun 2025 12:00:00 +0000')->toIso8601String()); +}); + +it('processes an Atom feed (YouTube) that the old RSS-only parser dropped', function () { + Carbon::setTestNow('2026-06-16 10:00:00'); + Http::fake(['1.1.1.1/*' => Http::response(file_get_contents(base_path('tests/fixtures/feeds/youtube_atom.xml')), 200)]); + + $automation = Automation::factory()->active()->create(); + + // Watermark predates the single entry, so it counts as new. + AutomationNodeState::create([ + 'automation_id' => $automation->id, + 'node_id' => 'fetch_1', + 'data' => ['last_item_date' => '2026-01-01T00:00:00+00:00'], + ]); + + $run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'fetch_1']); + + $result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://1.1.1.1/feed']); + + expect($result->status)->toBe(NodeRunStatus::Completed); + expect($result->output['fetch']['count'])->toBe(1); + expect($result->output['fetched']['title'])->toBe('Ninguém aguenta o TikTok Shop'); + expect($result->output['fetched']['link'])->toBe('https://www.youtube.com/watch?v=bIQVeW4sTcE'); + expect($result->output['fetched']['yt_videoId'])->toBe('bIQVeW4sTcE'); }); it('fails when feed_url is missing', function () { diff --git a/tests/Unit/Automation/FeedParserTest.php b/tests/Unit/Automation/FeedParserTest.php new file mode 100644 index 00000000..d12e948d --- /dev/null +++ b/tests/Unit/Automation/FeedParserTest.php @@ -0,0 +1,81 @@ +parser = new FeedParser; +}); + +it('parses an Atom (YouTube) feed with aliases and raw namespaced fields', function () { + $item = $this->parser->parse(feedFixture('youtube_atom'))[0]; + + // Aliases resolve cross-format: link comes from the Atom href attribute, + // date from , author from . + expect($item['title'])->toBe('Ninguém aguenta o TikTok Shop'); + expect($item['link'])->toBe('https://www.youtube.com/watch?v=bIQVeW4sTcE'); + expect($item['date'])->toBe('2026-06-15T20:24:32+00:00'); + expect($item['author'])->toBe('Beta Boechat'); + expect($item['key'])->toBe('yt:video:bIQVeW4sTcE'); + + // Raw namespaced fields stay reachable. + expect($item['yt_videoId'])->toBe('bIQVeW4sTcE'); + expect(data_get($item, 'media_group.media_thumbnail.url')) + ->toBe('https://i3.ytimg.com/vi/bIQVeW4sTcE/hqdefault.jpg'); + expect(data_get($item, 'media_group.media_description')) + ->toBe('Descrição completa do vídeo aqui.'); +}); + +it('parses an RSS 2.0 feed with dc and content namespaces', function () { + $item = $this->parser->parse(feedFixture('rss_namespaces'))[0]; + + expect($item['title'])->toBe('Polish is here'); + expect($item['link'])->toBe('https://feedback.changelogfy.com/en/polish-is-here'); + expect($item['author'])->toBe('Paulo Castellano'); + expect($item['key'])->toBe('16092'); + + // Extension fields the old parser threw away. + expect($item['dc_creator'])->toBe('Paulo Castellano'); + expect($item['content_encoded'])->toBe('

You can now serve your changelogs fully translated in Polish.

'); + expect($item['categories'])->toBe(['Feature', 'i18n']); +}); + +it('parses a podcast feed exposing the enclosure and itunes fields', function () { + $item = $this->parser->parse(feedFixture('podcast'))[0]; + + expect(data_get($item, 'enclosure.url'))->toBe('https://cdn.changelog.com/the-changelog-682.mp3'); + expect(data_get($item, 'enclosure.type'))->toBe('audio/mpeg'); + expect($item['itunes_duration'])->toBe('1:46:28'); + expect($item['key'])->toBe('changelog.com/1/2829'); +}); + +it('preserves backward-compatible RSS 2.0 alias keys', function () { + $item = $this->parser->parse(feedFixture('rss_namespaces'))[0]; + + // Automations saved before multi-format support reference these exact keys. + expect($item)->toHaveKeys(['title', 'link', 'description', 'pubDate', 'key']); + expect($item['pubDate'])->toBe($item['date']); +}); + +it('keeps a falsy element from a tag that only carries attributes', function () { + $item = $this->parser->parse(feedFixture('podcast'))[0]; + + // has no text — the attribute object is exposed. + expect(data_get($item, 'itunes_image.href'))->toBe('https://cdn.changelog.com/cover.png'); +}); + +it('returns null for a body that is not a valid feed', function () { + expect($this->parser->parse('this is not xml at all'))->toBeNull(); +}); + +it('returns an empty list for a valid but empty feed', function () { + $empty = ''; + + expect($this->parser->parse($empty))->toBe([]); +}); diff --git a/tests/fixtures/feeds/podcast.xml b/tests/fixtures/feeds/podcast.xml new file mode 100644 index 00000000..fa8c2068 --- /dev/null +++ b/tests/fixtures/feeds/podcast.xml @@ -0,0 +1,20 @@ + + + + The Changelog + https://changelog.com/podcast + Software news + + From open source hits to OpenAI + changelog.com/1/2829 + https://changelog.com/podcast/682 + Fri, 05 Jun 2026 19:00:00 +0000 + + This week I am talking with Max Stoiber. + full + + 1:46:28 + false + + + diff --git a/tests/fixtures/feeds/rss_namespaces.xml b/tests/fixtures/feeds/rss_namespaces.xml new file mode 100644 index 00000000..6a2157ec --- /dev/null +++ b/tests/fixtures/feeds/rss_namespaces.xml @@ -0,0 +1,20 @@ + + + + Changelogfy + Changelogfy + https://feedback.changelogfy.com + + + <![CDATA[Polish is here]]> + + https://feedback.changelogfy.com/en/polish-is-here + 16092 + Paulo Castellano + Thu, 05 Mar 2026 16:20:09 +0000 + + + You can now serve your changelogs fully translated in Polish.

]]>
+
+
+
diff --git a/tests/fixtures/feeds/youtube_atom.xml b/tests/fixtures/feeds/youtube_atom.xml new file mode 100644 index 00000000..b3370058 --- /dev/null +++ b/tests/fixtures/feeds/youtube_atom.xml @@ -0,0 +1,36 @@ + + + + yt:channel:123 + 123 + Beta Boechat + + + Beta Boechat + https://www.youtube.com/channel/UC123 + + 2015-08-11T18:07:31+00:00 + + yt:video:bIQVeW4sTcE + bIQVeW4sTcE + 123 + Ninguém aguenta o TikTok Shop + + + Beta Boechat + https://www.youtube.com/channel/UC123 + + 2026-06-15T20:24:32+00:00 + 2026-06-15T21:15:11+00:00 + + Ninguém aguenta o TikTok Shop + + + Descrição completa do vídeo aqui. + + + + + + + From 3acaafb81e41d764bf30d734dabe354a90ba4dde Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 11:06:46 -0300 Subject: [PATCH 2/7] test(automations): extract inline feed XML into shared fixtures Move the RSS sample feeds out of inline heredocs/constants in FetchRssNodeTest into tests/fixtures/feeds/, matching the parser fixtures. Centralize the feedFixture() loader in tests/Pest.php and use it across the feed tests. --- .../Feature/Automation/FeedInspectionTest.php | 4 +- .../Automation/Node/FetchRssNodeTest.php | 57 +++---------------- tests/Pest.php | 5 ++ tests/Unit/Automation/FeedParserTest.php | 5 -- tests/fixtures/feeds/rss_dateless.xml | 5 ++ tests/fixtures/feeds/rss_mixed.xml | 6 ++ tests/fixtures/feeds/rss_no_guid.xml | 4 ++ tests/fixtures/feeds/rss_old.xml | 5 ++ tests/fixtures/feeds/rss_three_new.xml | 6 ++ 9 files changed, 42 insertions(+), 55 deletions(-) create mode 100644 tests/fixtures/feeds/rss_dateless.xml create mode 100644 tests/fixtures/feeds/rss_mixed.xml create mode 100644 tests/fixtures/feeds/rss_no_guid.xml create mode 100644 tests/fixtures/feeds/rss_old.xml create mode 100644 tests/fixtures/feeds/rss_three_new.xml diff --git a/tests/Feature/Automation/FeedInspectionTest.php b/tests/Feature/Automation/FeedInspectionTest.php index 7d31baed..c5ab6d00 100644 --- a/tests/Feature/Automation/FeedInspectionTest.php +++ b/tests/Feature/Automation/FeedInspectionTest.php @@ -16,7 +16,7 @@ }); it('returns the discovered fields for a feed', function () { - Http::fake(['1.1.1.1/*' => Http::response(file_get_contents(base_path('tests/fixtures/feeds/youtube_atom.xml')), 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('youtube_atom'), 200)]); $automation = Automation::factory()->for($this->workspace)->create(); @@ -35,7 +35,7 @@ }); it('resolves workspace variables in the feed url before fetching', function () { - Http::fake(['1.1.1.1/*' => Http::response(file_get_contents(base_path('tests/fixtures/feeds/youtube_atom.xml')), 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('youtube_atom'), 200)]); $automation = Automation::factory()->for($this->workspace)->create([ 'variables' => [['key' => 'HOST', 'value' => '1.1.1.1']], diff --git a/tests/Feature/Automation/Node/FetchRssNodeTest.php b/tests/Feature/Automation/Node/FetchRssNodeTest.php index f915098e..dc83b8e3 100644 --- a/tests/Feature/Automation/Node/FetchRssNodeTest.php +++ b/tests/Feature/Automation/Node/FetchRssNodeTest.php @@ -15,37 +15,11 @@ afterEach(fn () => Carbon::setTestNow()); -const FETCH_RSS_OLD = <<<'XML' - - - Ancienthttps://1.1.1.1/11Mon, 01 Jan 2024 12:00:00 +0000 - Oldhttps://1.1.1.1/22Sat, 01 Feb 2025 12:00:00 +0000 - -XML; - -const FETCH_RSS_MIXED = <<<'XML' - - - Olderhttps://1.1.1.1/aaSat, 01 Feb 2025 12:00:00 +0000 - Newer1https://1.1.1.1/bbSun, 01 Jun 2025 12:00:00 +0000 - Newer2https://1.1.1.1/ccSun, 15 Jun 2025 12:00:00 +0000 - -XML; - -const FETCH_RSS_THREE_NEW = <<<'XML' - - - New1https://1.1.1.1/xxSun, 01 Jun 2025 12:00:00 +0000 - New2https://1.1.1.1/yyMon, 02 Jun 2025 12:00:00 +0000 - New3https://1.1.1.1/zzTue, 03 Jun 2025 12:00:00 +0000 - -XML; - beforeEach(fn () => Bus::fake()); it('first execution dispatches nothing and stores watermark from newest item', function () { Carbon::setTestNow('2026-01-15 10:00:00'); - Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_OLD, 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_old'), 200)]); $automation = Automation::factory()->active()->create([ 'nodes' => [ @@ -74,7 +48,7 @@ it('processes the first new item on the current run and spawns siblings for the rest', function () { Carbon::setTestNow('2026-01-15 10:00:00'); - Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_MIXED, 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_mixed'), 200)]); $automation = Automation::factory()->active()->create([ 'nodes' => [ @@ -111,7 +85,7 @@ it('spawns sibling runs down the item edge for each remaining new item', function () { Carbon::setTestNow('2026-01-15 10:00:00'); - Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_THREE_NEW, 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_three_new'), 200)]); $automation = Automation::factory()->active()->create([ 'nodes' => [ @@ -151,7 +125,7 @@ it('fans every spawned item out across all branches wired to the fetch node', function () { Carbon::setTestNow('2026-01-15 10:00:00'); - Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_THREE_NEW, 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_three_new'), 200)]); $automation = Automation::factory()->active()->create([ 'nodes' => [ @@ -185,7 +159,7 @@ it('does not persist the production watermark on a manual real-data test', function () { Carbon::setTestNow('2026-01-15 10:00:00'); - Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_MIXED, 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_mixed'), 200)]); $automation = Automation::factory()->active()->create([ 'nodes' => [ @@ -226,7 +200,7 @@ it('advances the production watermark on a non-manual real-data run', function () { Carbon::setTestNow('2026-01-15 10:00:00'); - Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_MIXED, 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_mixed'), 200)]); $automation = Automation::factory()->active()->create([ 'nodes' => [ @@ -261,7 +235,7 @@ it('processes an Atom feed (YouTube) that the old RSS-only parser dropped', function () { Carbon::setTestNow('2026-06-16 10:00:00'); - Http::fake(['1.1.1.1/*' => Http::response(file_get_contents(base_path('tests/fixtures/feeds/youtube_atom.xml')), 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('youtube_atom'), 200)]); $automation = Automation::factory()->active()->create(); @@ -317,14 +291,7 @@ it('skips items without a publish date', function () { Carbon::setTestNow('2026-01-15 10:00:00'); - $feed = <<<'XML' - - - Datedhttps://1.1.1.1/ddSun, 01 Jun 2025 12:00:00 +0000 - NoDatehttps://1.1.1.1/nn - - XML; - Http::fake(['1.1.1.1/*' => Http::response($feed, 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_dateless'), 200)]); $automation = Automation::factory()->active()->create(); AutomationNodeState::create([ @@ -343,13 +310,7 @@ it('falls back to the link as the dedup key when an item has no guid', function () { Carbon::setTestNow('2026-01-15 10:00:00'); - $feed = <<<'XML' - - - LinkKeyhttps://1.1.1.1/only-linkSun, 01 Jun 2025 12:00:00 +0000 - - XML; - Http::fake(['1.1.1.1/*' => Http::response($feed, 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_no_guid'), 200)]); $automation = Automation::factory()->active()->create(); AutomationNodeState::create([ diff --git a/tests/Pest.php b/tests/Pest.php index 373a3c94..523a1605 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -103,3 +103,8 @@ function createApiTestToken(array $overrides = []): array 'user' => $user, ]; } + +function feedFixture(string $name): string +{ + return file_get_contents(base_path("tests/fixtures/feeds/{$name}.xml")); +} diff --git a/tests/Unit/Automation/FeedParserTest.php b/tests/Unit/Automation/FeedParserTest.php index d12e948d..cda1a74b 100644 --- a/tests/Unit/Automation/FeedParserTest.php +++ b/tests/Unit/Automation/FeedParserTest.php @@ -4,11 +4,6 @@ use App\Services\Automation\FeedParser; -function feedFixture(string $name): string -{ - return file_get_contents(base_path("tests/fixtures/feeds/{$name}.xml")); -} - beforeEach(function () { $this->parser = new FeedParser; }); diff --git a/tests/fixtures/feeds/rss_dateless.xml b/tests/fixtures/feeds/rss_dateless.xml new file mode 100644 index 00000000..354740d3 --- /dev/null +++ b/tests/fixtures/feeds/rss_dateless.xml @@ -0,0 +1,5 @@ + + + Datedhttps://1.1.1.1/ddSun, 01 Jun 2025 12:00:00 +0000 + NoDatehttps://1.1.1.1/nn + diff --git a/tests/fixtures/feeds/rss_mixed.xml b/tests/fixtures/feeds/rss_mixed.xml new file mode 100644 index 00000000..e0ff0f35 --- /dev/null +++ b/tests/fixtures/feeds/rss_mixed.xml @@ -0,0 +1,6 @@ + + + Olderhttps://1.1.1.1/aaSat, 01 Feb 2025 12:00:00 +0000 + Newer1https://1.1.1.1/bbSun, 01 Jun 2025 12:00:00 +0000 + Newer2https://1.1.1.1/ccSun, 15 Jun 2025 12:00:00 +0000 + diff --git a/tests/fixtures/feeds/rss_no_guid.xml b/tests/fixtures/feeds/rss_no_guid.xml new file mode 100644 index 00000000..50da87ed --- /dev/null +++ b/tests/fixtures/feeds/rss_no_guid.xml @@ -0,0 +1,4 @@ + + + LinkKeyhttps://1.1.1.1/only-linkSun, 01 Jun 2025 12:00:00 +0000 + diff --git a/tests/fixtures/feeds/rss_old.xml b/tests/fixtures/feeds/rss_old.xml new file mode 100644 index 00000000..89742385 --- /dev/null +++ b/tests/fixtures/feeds/rss_old.xml @@ -0,0 +1,5 @@ + + + Ancienthttps://1.1.1.1/11Mon, 01 Jan 2024 12:00:00 +0000 + Oldhttps://1.1.1.1/22Sat, 01 Feb 2025 12:00:00 +0000 + diff --git a/tests/fixtures/feeds/rss_three_new.xml b/tests/fixtures/feeds/rss_three_new.xml new file mode 100644 index 00000000..6d44e06d --- /dev/null +++ b/tests/fixtures/feeds/rss_three_new.xml @@ -0,0 +1,6 @@ + + + New1https://1.1.1.1/xxSun, 01 Jun 2025 12:00:00 +0000 + New2https://1.1.1.1/yyMon, 02 Jun 2025 12:00:00 +0000 + New3https://1.1.1.1/zzTue, 03 Jun 2025 12:00:00 +0000 + From 4bb7d450a3d99e61e4ae098dae38df27194544f8 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 11:09:25 -0300 Subject: [PATCH 3/7] chore(release): link GitHub release in changelog email + add v1.0.2 artifacts Tweak the /release command's email template (GitHub release link in the byline, "Paulo from TryPost.it" signature) and add the generated v1.0.2 release notes. --- .claude/commands/release.md | 18 ++++++++------ releases/v1.0.2/changelog.md | 24 ++++++++++++++++++ releases/v1.0.2/email.md | 47 ++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 releases/v1.0.2/changelog.md create mode 100644 releases/v1.0.2/email.md diff --git a/.claude/commands/release.md b/.claude/commands/release.md index b3c74a94..8eb67224 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -89,12 +89,12 @@ #### Structure ```markdown --- -subject: "Changelog: TryPost — , , ..." +subject: "Changelog — , , ..." --- -# Changelog: TryPost — , , ... +# Changelog — , , ... -By Paulo Castellano • Release +By TryPost Product Team • [Release ](https://github.com///releases/tag/) Hello! Welcome to this week's update. Here's what's new in TryPost. @@ -121,9 +121,11 @@ ## Fixes - <...> Cheers, -Paulo Castellano from TryPost.it +Paulo from TryPost.it ``` +**Always link the GitHub release from the byline** — make `Release ` a link to `https://github.com///releases/tag/` (as shown above). It gives developer-minded readers the raw PR-level changelog without cluttering the body. + #### Theme grouping (AI clusters by user impact) Read all commits since the last tag and cluster into **2-3 user-facing themes**. Use whatever frame makes the changes feel coherent to a customer, not to a developer. @@ -159,7 +161,9 @@ #### Bullet rules for "New features" / "Fixes" #### Subject line -Pattern: `Changelog: TryPost — , , ...` +Pattern: `Changelog — , , ...` + +Don't put "TryPost" in the subject — the email already comes from the TryPost sender, so it's redundant. Cap around 80 chars. If themes don't fit, shorten to the 2 most impactful + "and more...". @@ -167,13 +171,13 @@ ### Step 5 — Humanize the email prose Run the email body through the `humanizer` skill before previewing: -1. Invoke the `Skill` tool with `skill: humanizer` and pass the draft email body plus this context: *"This is a customer-facing changelog email for TryPost (social media scheduler SaaS). Tone: developer founder writing to early users on a Friday — warm, specific, no marketing puffery. Cal.com style. Keep the existing structure (subject frontmatter, section headers, bullets, signature). Do not strip section headers or the 'Cheers, Paulo Castellano from TryPost.it' signature."* +1. Invoke the `Skill` tool with `skill: humanizer` and pass the draft email body plus this context: *"This is a customer-facing changelog email for TryPost (social media scheduler SaaS). Tone: developer founder writing to early users on a Friday — warm, specific, no marketing puffery. Cal.com style. Keep the existing structure (subject frontmatter, section headers, bullets, signature). Do not strip section headers or the 'Cheers, Paulo from TryPost.it' signature."* 2. Replace the draft email body with the humanized version. **Do NOT humanize:** - The changelog from Step 3 (flat commit list, no prose). - The subject line frontmatter. -- The literal signature `Cheers,\nPaulo Castellano from TryPost.it` — keep it exact. +- The literal signature `Cheers,\nPaulo from TryPost.it` — keep it exact. The humanizer skill itself covers all patterns. Trust it. diff --git a/releases/v1.0.2/changelog.md b/releases/v1.0.2/changelog.md new file mode 100644 index 00000000..105bee6a --- /dev/null +++ b/releases/v1.0.2/changelog.md @@ -0,0 +1,24 @@ +## What's Changed +* Update GitHub funding username by @paulocastellano in https://github.com/trypostit/trypost/pull/67 +* Update star history section for dark and light themes by @paulocastellano in https://github.com/trypostit/trypost/pull/75 +* Fix workspace name-only update rejected by required brand fields by @paulocastellano in https://github.com/trypostit/trypost/pull/77 +* Fix calendar showing "no content" for every post by @paulocastellano in https://github.com/trypostit/trypost/pull/78 +* Stop persisting instagram_carousel as a content type by @paulocastellano in https://github.com/trypostit/trypost/pull/80 +* fix: Updates symfony packages from 8.0.x to 8.1.0 to address the following security advisories: by @sebastian-works in https://github.com/trypostit/trypost/pull/76 +* fix(tiktok): align UX strings and render processing notice for app review by @paulocastellano in https://github.com/trypostit/trypost/pull/71 +* Add Copilot code review instructions by @paulocastellano in https://github.com/trypostit/trypost/pull/85 +* Aspect ratio selection for Facebook posts (like Instagram) by @paulocastellano in https://github.com/trypostit/trypost/pull/82 +* Post platform meta (aspect ratio) parity across API and MCP by @paulocastellano in https://github.com/trypostit/trypost/pull/86 +* fix(linkedin): drop deprecated r_basicprofile from default scopes by @Falconiere in https://github.com/trypostit/trypost/pull/68 +* Rename post editor 'Schedule' tab to 'Channels' by @paulocastellano in https://github.com/trypostit/trypost/pull/87 +* Automations ⚙️ by @paulocastellano in https://github.com/trypostit/trypost/pull/88 +* Fix: resolve Bluesky @mention handles to DIDs (mentions caused "Invalid post data") by @Schrall in https://github.com/trypostit/trypost/pull/93 +* Harden BlueskyPublisher: link facets, conventions, NSID lexicon, tests by @paulocastellano in https://github.com/trypostit/trypost/pull/94 +* Add Telegram publishing channel by @paulocastellano in https://github.com/trypostit/trypost/pull/95 +* Past-due access, reliable checkout.completed, and complete morph map by @paulocastellano in https://github.com/trypostit/trypost/pull/96 + +## New Contributors +* @Falconiere made their first contribution in https://github.com/trypostit/trypost/pull/68 +* @Schrall made their first contribution in https://github.com/trypostit/trypost/pull/93 + +**Full Changelog**: https://github.com/trypostit/trypost/compare/v1.0.1...v1.0.2 diff --git a/releases/v1.0.2/email.md b/releases/v1.0.2/email.md new file mode 100644 index 00000000..220305d1 --- /dev/null +++ b/releases/v1.0.2/email.md @@ -0,0 +1,47 @@ +--- +subject: "Changelog v1.0.2 — We're on Discord, plus Automations & Telegram" +--- + +# Changelog v1.0.2 — We're on Discord, plus Automations & Telegram + +By TryPost Product Team • [Release v1.0.2](https://github.com/trypostit/trypost/releases/tag/v1.0.2) + +Hey — quick rundown of what shipped this week. But first, the thing I'm most excited about: + +## We have a Discord now + +We just opened the TryPost community on Discord. It's where we'll share what we're building, take your feature requests, and help each other get more out of the tool. I'll be in there myself, so come say hi and tell me what you want us to build next. + +**[Join us on Discord →](https://trypost.it/discord)** + +Or just head to [trypost.it/discord](https://trypost.it/discord) + +## Automations + +You can now hand the posting off to TryPost. Point an automation at a source like an RSS feed, and it'll draft a post in your brand voice and schedule it to your channels on whatever cadence you set. There's a visual editor where you wire up the trigger, conditions, and steps, and tabs to watch each run and see what it actually did. + +## Telegram + +Telegram is a channel now. Add our bot to your channel or group, drop in the one-time connect code, and you're publishing text and media albums. You'll also see per-post reactions and your subscriber count next to the rest of your analytics. + +## Sharper posts on every network + +Facebook posts let you pick an aspect ratio now, the same way Instagram does, so your images aren't cropped in ways you didn't choose. And on Bluesky, @mentions and links finally resolve properly — tagging someone no longer throws an "invalid post" error. + +## New features + +- Build automations that draft and schedule posts for you +- Connect Telegram channels and groups, with reactions and subscriber stats +- Pick the aspect ratio for Facebook image posts +- Mention people and add links on Bluesky + +## Fixes + +- The calendar no longer shows "no content" on posts that clearly have content +- Renaming a workspace doesn't make you re-fill brand details anymore +- If a payment fails, you keep your access and can just update your card instead of starting a new subscription +- The editor's "Schedule" tab is now called "Channels", which is what it always was +- A handful of TikTok and Instagram posting fixes + +Cheers, +Paulo from TryPost.it From 109ad7dfc041657451bed75e97e2e809a279916a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 11:26:05 -0300 Subject: [PATCH 4/7] fix(automations): preview shows the newest feed item, not the oldest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A manual/dry "test with real data" run surfaces a single item and never fans out, but it was taking the oldest new item (items are sorted oldest-first for production fan-out). Surface the newest item in preview instead — what the user expects to test against. Real runs are unchanged. Works for every feed format. --- .../Automation/Node/RunFetchRssNode.php | 18 ++++++++++++++---- .../Automation/Node/FetchRssNodeTest.php | 5 +++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/app/Actions/Automation/Node/RunFetchRssNode.php b/app/Actions/Automation/Node/RunFetchRssNode.php index a8394ee7..3482d985 100644 --- a/app/Actions/Automation/Node/RunFetchRssNode.php +++ b/app/Actions/Automation/Node/RunFetchRssNode.php @@ -96,14 +96,24 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: self::NO_ITEMS_HANDLE); } - $first = array_shift($newItems); + $total = count($newItems); - if (! $isPreview) { - $this->spawnSiblings($run, $nodeId, $newItems); + // A preview (manual/dry test) surfaces ONE item and never fans out, so it + // shows the newest item — what the user expects to test against. A real run + // takes the oldest new item and fans the rest out as siblings, preserving + // feed chronology across branches (items are sorted oldest-first). + if ($isPreview) { + return NodeRunResult::completed([ + 'fetch' => ['count' => $total, 'spawned' => 0], + 'fetched' => end($newItems), + ]); } + $first = array_shift($newItems); + $this->spawnSiblings($run, $nodeId, $newItems); + return NodeRunResult::completed([ - 'fetch' => ['count' => count($newItems) + 1, 'spawned' => $isPreview ? 0 : count($newItems)], + 'fetch' => ['count' => $total, 'spawned' => count($newItems)], 'fetched' => $first, ]); } diff --git a/tests/Feature/Automation/Node/FetchRssNodeTest.php b/tests/Feature/Automation/Node/FetchRssNodeTest.php index dc83b8e3..69189545 100644 --- a/tests/Feature/Automation/Node/FetchRssNodeTest.php +++ b/tests/Feature/Automation/Node/FetchRssNodeTest.php @@ -193,6 +193,11 @@ expect($result->status)->toBe(NodeRunStatus::Completed); expect($result->output['fetch']['count'])->toBeGreaterThan(0); + // ...surfaces the NEWEST item (key 'c', 15 Jun) — not the oldest — so the + // test reflects what the user just published, and spawns no siblings. + expect($result->output['fetched']['key'])->toBe('c'); + expect($result->output['fetch']['spawned'])->toBe(0); + // ...and never advances the persisted watermark. $state = AutomationNodeState::for($automation->id, 'fetch_1'); expect($state->data['last_item_date'])->toBe('2030-01-01T00:00:00+00:00'); From b2932262cf6561af8a3d5dadbf084af8630e9234 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 11:26:05 -0300 Subject: [PATCH 5/7] fix(automations): reliably open a node's config when switching nodes A click with a few px of jitter crossed Vue Flow's 1px drag threshold and was treated as a drag, so onNodeClick never fired and the config panel didn't switch between nodes. Raise nodeDragThreshold to 5 so normal clicks stay clicks, and also select the node on drag start as a safety net. --- resources/js/pages/automations/Form.vue | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/resources/js/pages/automations/Form.vue b/resources/js/pages/automations/Form.vue index b3afe367..6a2748f6 100644 --- a/resources/js/pages/automations/Form.vue +++ b/resources/js/pages/automations/Form.vue @@ -206,6 +206,11 @@ onConnect((connection: Connection) => { let dragStartPosition: XYPosition | null = null; onNodeDragStart(({ node }) => { dragStartPosition = { ...node.position }; + // A click that moves more than Vue Flow's drag threshold (1px) registers as a + // drag, not a click, so onNodeClick never fires. Select here too, otherwise + // switching between nodes intermittently fails to open the grabbed node's config. + selectedNodeId.value = node.id; + selectedEdgeId.value = null; }); onNodeDragStop(({ node }) => { if (!dragStartPosition) return; @@ -483,6 +488,7 @@ const defaultEdgeOptions = { :default-edge-options="defaultEdgeOptions" :connection-mode="ConnectionMode.Loose" :delete-key-code="null" + :node-drag-threshold="5" :snap-to-grid="true" :snap-grid="[16, 16]" fit-view-on-init From f55d35363f3c4f9fd7e5637f31e4c31651cc1d22 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 12:09:16 -0300 Subject: [PATCH 6/7] fix(automations): return null on any feed parse failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap FeedParser::parse in try/catch so an unexpected Throwable from third-party feed content (not just invalid XML) maps to the clean fetch_rss_malformed failure instead of bubbling up as an unhandled exception — matching the failed()-not-throw pattern of the sibling HTTP node. --- app/Services/Automation/FeedParser.php | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/app/Services/Automation/FeedParser.php b/app/Services/Automation/FeedParser.php index 52bc9ae6..340c13ee 100644 --- a/app/Services/Automation/FeedParser.php +++ b/app/Services/Automation/FeedParser.php @@ -6,6 +6,7 @@ use SimplePie\Item; use SimplePie\SimplePie; +use Throwable; /** * Parses an RSS 2.0 / Atom 1.0 / RDF feed body into normalized item arrays. @@ -52,19 +53,24 @@ class FeedParser ]; /** - * @return list>|null Null when the body is not a valid feed. + * @return list>|null Null when the body is not a usable feed + * (invalid XML, or any parse failure). */ public function parse(string $body): ?array { - $feed = new SimplePie; - $feed->enable_cache(false); - $feed->set_raw_data($body); + try { + $feed = new SimplePie; + $feed->enable_cache(false); + $feed->set_raw_data($body); - if (! @$feed->init()) { + if (! @$feed->init()) { + return null; + } + + return array_map(fn (Item $item): array => $this->normalize($item), $feed->get_items()); + } catch (Throwable) { return null; } - - return array_map(fn (Item $item): array => $this->normalize($item), $feed->get_items()); } /** From 45da024fc682850bd5b20257e91399d8d644d75f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 12:09:16 -0300 Subject: [PATCH 7/7] fix(automations): code-review hardening for feed fetch & URL validation - inspectFeed fetches via SafeHttpFetcher::get (SSRF guard + timeout + redirect cap + UA) instead of a raw, timeout-less Http::get; add a timeout to the node fetch. - ResolvableUrl now requires an http(s) scheme, so it's no weaker than the plain url rule it replaced (rejects file://, javascript://). - Apply ResolvableUrl to the HTTP Request and Webhook node URLs too, so templated {{ }} URLs validate consistently across every URL field. - Clear a fetch_rss node's discovered_fields when its feed_url changes, so stale fields from the previous feed stop showing up as autocomplete suggestions. --- .../Automation/Node/RunFetchRssNode.php | 2 +- .../Controllers/App/AutomationController.php | 12 ++--- .../Automations/UpdateAutomationRequest.php | 4 +- app/Rules/ResolvableUrl.php | 6 ++- .../automations/config/FetchRssNodeConfig.vue | 9 ++++ .../Feature/Automation/FeedInspectionTest.php | 54 +++++++++++++++++++ 6 files changed, 75 insertions(+), 12 deletions(-) diff --git a/app/Actions/Automation/Node/RunFetchRssNode.php b/app/Actions/Automation/Node/RunFetchRssNode.php index 3482d985..6bf2a388 100644 --- a/app/Actions/Automation/Node/RunFetchRssNode.php +++ b/app/Actions/Automation/Node/RunFetchRssNode.php @@ -60,7 +60,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult ]); } - $response = Http::get($feedUrl); + $response = Http::timeout(10)->get($feedUrl); if (! $response->successful()) { return NodeRunResult::failed(__('automations.errors.fetch_rss_request_failed'), ['status' => $response->status()]); diff --git a/app/Http/Controllers/App/AutomationController.php b/app/Http/Controllers/App/AutomationController.php index b31c74c4..e691eb8c 100644 --- a/app/Http/Controllers/App/AutomationController.php +++ b/app/Http/Controllers/App/AutomationController.php @@ -39,7 +39,6 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Response as HttpResponse; -use Illuminate\Support\Facades\Http; use Inertia\Inertia; use Inertia\Response; use RuntimeException; @@ -225,15 +224,12 @@ public function inspectFeed( ['variables' => $automation->resolvedVariables()], ); + // SafeHttpFetcher::get() bundles the SSRF guard, a request timeout, a + // redirect cap and a branded user-agent — so a slow or hostile feed can't + // hang this synchronous request. It throws on SSRF, timeout or non-2xx. try { - $safeHttp->guardAgainstSsrf($feedUrl); + $response = $safeHttp->get($feedUrl); } catch (RuntimeException) { - return response()->json(['message' => __('automations.errors.url_not_allowed')], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY); - } - - $response = Http::get($feedUrl); - - if (! $response->successful()) { return response()->json(['message' => __('automations.errors.fetch_rss_request_failed')], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY); } diff --git a/app/Http/Requests/App/Automations/UpdateAutomationRequest.php b/app/Http/Requests/App/Automations/UpdateAutomationRequest.php index ed94fa4b..0b0a3677 100644 --- a/app/Http/Requests/App/Automations/UpdateAutomationRequest.php +++ b/app/Http/Requests/App/Automations/UpdateAutomationRequest.php @@ -136,7 +136,7 @@ private function dataRulesForNodeType(?string $type, int $i): array 'discovered_fields.*.sample' => ['nullable', 'string'], ], NodeType::HttpRequest->value => [ - 'url' => ['required', 'url'], + 'url' => ['required', new ResolvableUrl], 'method' => ['required', Rule::in(array_column(HttpMethod::cases(), 'value'))], 'auth_type' => ['required', Rule::in(array_column(AuthType::cases(), 'value'))], 'auth_token' => ['nullable', 'string'], @@ -171,7 +171,7 @@ private function dataRulesForNodeType(?string $type, int $i): array 'scheduled_offset' => ['required_if:nodes.'.$i.'.data.mode,'.PublishMode::Scheduled->value, 'integer', 'min:0'], ], NodeType::Webhook->value => [ - 'url' => ['required', 'url'], + 'url' => ['required', new ResolvableUrl], 'method' => ['required', Rule::in(array_column(HttpMethod::cases(), 'value'))], 'payload_template' => ['nullable', 'string'], 'headers' => ['nullable', 'array'], diff --git a/app/Rules/ResolvableUrl.php b/app/Rules/ResolvableUrl.php index af5db97d..45f899bf 100644 --- a/app/Rules/ResolvableUrl.php +++ b/app/Rules/ResolvableUrl.php @@ -28,8 +28,12 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } $candidate = preg_replace('/\{\{\s*[\w.]+\s*\}\}/', 'placeholder', $value); + $scheme = strtolower((string) parse_url($candidate, PHP_URL_SCHEME)); - if (filter_var($candidate, FILTER_VALIDATE_URL) === false) { + // Require a real http(s) URL once expressions are substituted, so the rule + // is no weaker than the plain `url` rule it replaces (rejects file://, + // javascript://, etc.) while still allowing templated hosts/queries. + if (filter_var($candidate, FILTER_VALIDATE_URL) === false || ! in_array($scheme, ['http', 'https'], true)) { $fail('validation.url')->translate(); } } diff --git a/resources/js/components/automations/config/FetchRssNodeConfig.vue b/resources/js/components/automations/config/FetchRssNodeConfig.vue index 74249ae5..886ba05b 100644 --- a/resources/js/components/automations/config/FetchRssNodeConfig.vue +++ b/resources/js/components/automations/config/FetchRssNodeConfig.vue @@ -35,6 +35,15 @@ const local = ref({ watch(local, (val) => emit('update', val), { deep: true }); +// Changing the feed invalidates the discovered fields — they belong to the old +// feed and would otherwise keep being offered as stale autocomplete suggestions. +watch( + () => local.value.feed_url, + () => { + local.value.discovered_fields = []; + }, +); + const inspectHttp = useHttp<{ feed_url: string }, { fields: DiscoveredField[] }>({ feed_url: '' }); const isInspecting = ref(false); const inspectError = ref(null); diff --git a/tests/Feature/Automation/FeedInspectionTest.php b/tests/Feature/Automation/FeedInspectionTest.php index c5ab6d00..1889529a 100644 --- a/tests/Feature/Automation/FeedInspectionTest.php +++ b/tests/Feature/Automation/FeedInspectionTest.php @@ -125,6 +125,60 @@ ->assertSessionHasErrors('nodes.0.data.feed_url'); }); +it('rejects a non-http(s) scheme in feed_url', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->put(route('app.automations.update', $automation->id), [ + 'nodes' => [[ + 'id' => 'fetch_1', + 'type' => 'fetch_rss', + 'position' => ['x' => 0, 'y' => 0], + 'data' => ['feed_url' => 'file:///etc/passwd'], + ]], + ]) + ->assertSessionHasErrors('nodes.0.data.feed_url'); +}); + +it('accepts a templated url on an http_request node', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->put(route('app.automations.update', $automation->id), [ + 'nodes' => [[ + 'id' => 'http_1', + 'type' => 'http_request', + 'position' => ['x' => 0, 'y' => 0], + 'data' => [ + 'url' => 'https://api.example.com/{{ variables.PATH }}/feed', + 'method' => 'GET', + 'auth_type' => 'none', + ], + ]], + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); +}); + +it('accepts a templated url on a webhook node', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->put(route('app.automations.update', $automation->id), [ + 'nodes' => [[ + 'id' => 'webhook_1', + 'type' => 'webhook', + 'position' => ['x' => 0, 'y' => 0], + 'data' => [ + 'url' => 'https://hooks.example.com/{{ variables.TOKEN }}', + 'method' => 'POST', + ], + ]], + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); +}); + it('forbids inspecting a feed for an automation in another workspace', function () { Http::fake(['1.1.1.1/*' => Http::response('', 200)]);