Skip to content
10 changes: 10 additions & 0 deletions lib/Controller/Implementation/RecipeImplementation.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
namespace OCA\Cookbook\Controller\Implementation;

use Exception;
use OCA\Cookbook\Exception\ImportException;
use OCA\Cookbook\Exception\InvalidDownloadURLException;
use OCA\Cookbook\Exception\InvalidJSONFileException;
use OCA\Cookbook\Exception\NoRecipeNameGivenException;
Expand All @@ -15,6 +16,7 @@
use OCA\Cookbook\Helper\Filter\Output\RecipeJSONOutputFilter;
use OCA\Cookbook\Helper\Filter\Output\RecipeStubFilter;
use OCA\Cookbook\Helper\RestParameterParser;
use OCA\Cookbook\Helper\UrlRedactionHelper;
use OCA\Cookbook\Service\DbCacheService;
use OCA\Cookbook\Service\RecipeService;
use OCP\AppFramework\Http;
Expand Down Expand Up @@ -124,7 +126,7 @@
*
* @param $id The id of the recipe in question
* @return JSONResponse
* @todo Parameter id is never used. Fix that

Check warning on line 129 in lib/Controller/Implementation/RecipeImplementation.php

View workflow job for this annotation

GitHub Actions / Check for added todo messages

Found @todo Parameter id is never used. Fix that
*/
public function update($id) {
$this->dbCacheService->triggerCheck();
Expand Down Expand Up @@ -274,7 +276,7 @@
}

/**
* @todo Move this code into a dedicated class and service

Check warning on line 279 in lib/Controller/Implementation/RecipeImplementation.php

View workflow job for this annotation

GitHub Actions / Check for added todo messages

Found @todo Move this code into a dedicated class and service
*/
private function isDownloadUrlValid(string $url): bool {
$urlValid = filter_var($url, FILTER_VALIDATE_URL);
Expand Down Expand Up @@ -352,7 +354,15 @@
];

return new JSONResponse($json, Http::STATUS_CONFLICT);
} catch (ImportException $e) {
$this->logger->warning('Import failed at step "{step}": {reason}', [
'url' => UrlRedactionHelper::redact($data['url']), 'step' => $e->getStep(), 'reason' => $e->getMessage(), 'exception' => $e,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you put more entries into the array of snippet (second argument)?

]);
return new JSONResponse($e->getMessage(), 400);
} catch (\Exception $e) {
$this->logger->error('Unexpected error during import: {reason}', [
'url' => UrlRedactionHelper::redact($data['url']), 'reason' => $e->getMessage(), 'exception' => $e,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Url and exception seem not used, isn't it?

]);
return new JSONResponse($e->getMessage(), 400);
}

Expand Down
9 changes: 8 additions & 1 deletion lib/Exception/ImportException.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@
namespace OCA\Cookbook\Exception;

class ImportException extends \Exception {
public function __construct($message = '', $code = 0, $previous = null) {
private string $step;

public function __construct($message = '', $code = 0, $previous = null, string $step = '') {
parent::__construct($message, $code, $previous);
$this->step = $step;
}

public function getStep(): string {
return $this->step;
}
}
6 changes: 5 additions & 1 deletion lib/Helper/HTMLParser/AbstractHtmlParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@

use OCA\Cookbook\Exception\HtmlParsingException;
use OCP\IL10N;
use Psr\Log\LoggerInterface;

abstract class AbstractHtmlParser {
/**
* @var IL10N
*/
protected $l;

public function __construct(IL10N $l10n) {
protected LoggerInterface $logger;

public function __construct(IL10N $l10n, LoggerInterface $logger) {
$this->l = $l10n;
$this->logger = $logger;
}

/**
Expand Down
15 changes: 12 additions & 3 deletions lib/Helper/HTMLParser/HttpJsonLdParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
namespace OCA\Cookbook\Helper\HTMLParser;

use OCA\Cookbook\Exception\HtmlParsingException;
use OCA\Cookbook\Helper\UrlRedactionHelper;
use OCA\Cookbook\Service\JsonService;
use OCP\IL10N;
use Psr\Log\LoggerInterface;

/**
* This class is an AbstractHtmlParser which tries to extract a JSON+LD script from the HTML page.
Expand All @@ -20,8 +22,8 @@
*/
private $jsonService;

public function __construct(IL10N $l10n, JsonService $jsonService) {
parent::__construct($l10n);
public function __construct(IL10N $l10n, LoggerInterface $logger, JsonService $jsonService) {
parent::__construct($l10n, $logger);

$this->jsonService = $jsonService;
}
Expand All @@ -32,6 +34,10 @@

$json_ld_elements = $xpath->query("//*[@type='application/ld+json']");

$this->logger->debug('JSON-LD parser found {count} ld+json elements for {url}', [
'count' => $json_ld_elements->length, 'url' => UrlRedactionHelper::redact($url),
]);

foreach ($json_ld_elements as $json_ld_element) {
if (!$json_ld_element || !$json_ld_element->nodeValue) {
continue;
Expand All @@ -40,7 +46,9 @@
try {
return $this->parseJsonLdElement($json_ld_element);
} catch (HtmlParsingException $ex) {
// Parsing failed for this element. Let's see if there are more...
$this->logger->debug('JSON-LD element did not contain a valid recipe: {reason}', [
'reason' => $ex->getMessage(),
]);
}
}

Expand All @@ -62,6 +70,7 @@
$json = json_decode($string, true);

if ($json === null) {
$this->logger->debug('JSON-LD decode failed: {error}', ['error' => json_last_error_msg()]);
throw new HtmlParsingException($this->l->t('JSON cannot be decoded.'));
}

Expand Down Expand Up @@ -120,7 +129,7 @@
* Some sites use the @graph property to define elements.
* This is a quick workaround to extract the corresponding recipe.
*
* @todo This only extracts the very first recipe in the graph and only that.

Check warning on line 132 in lib/Helper/HTMLParser/HttpJsonLdParser.php

View workflow job for this annotation

GitHub Actions / Check for added todo messages

Found @todo This only extracts the very first recipe in the graph and only that.
* It might be favorable to look further into the json objects.
* This might especially be true when the recipe uses links to external JSON objects
* (as specified by the standard).
Expand Down
11 changes: 9 additions & 2 deletions lib/Helper/HTMLParser/HttpMicrodataParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@
use DOMNodeList;
use DOMXPath;
use OCA\Cookbook\Exception\HtmlParsingException;
use OCA\Cookbook\Helper\UrlRedactionHelper;
use OCP\IL10N;
use Psr\Log\LoggerInterface;

/**
* This class is an AbstractHtmlParser which tries to extract micro data from the HTML page.
* @author Christian Wolf
* @todo Nutrition data is missing

Check warning on line 22 in lib/Helper/HTMLParser/HttpMicrodataParser.php

View workflow job for this annotation

GitHub Actions / Check for added todo messages

Found @todo Nutrition data is missing
* @todo Category needs checking

Check warning on line 23 in lib/Helper/HTMLParser/HttpMicrodataParser.php

View workflow job for this annotation

GitHub Actions / Check for added todo messages

Found @todo Category needs checking
* @todo Tools need to be imported

Check warning on line 24 in lib/Helper/HTMLParser/HttpMicrodataParser.php

View workflow job for this annotation

GitHub Actions / Check for added todo messages

Found @todo Tools need to be imported
*/
class HttpMicrodataParser extends AbstractHtmlParser {
/**
Expand All @@ -32,8 +34,8 @@
*/
private $recipe;

public function __construct(IL10N $l10n) {
parent::__construct($l10n);
public function __construct(IL10N $l10n, LoggerInterface $logger) {
parent::__construct($l10n, $logger);
}

#[\Override]
Expand All @@ -49,9 +51,14 @@
$recipes = $this->xpath->query($xpathSelector);

if (count($recipes) === 0) {
$this->logger->info('Microdata parser found no Recipe itemscope elements for {url}', ['url' => UrlRedactionHelper::redact($url)]);
throw new HtmlParsingException($this->l->t('No recipe was found.'));
}

$this->logger->debug('Microdata parser found {count} Recipe itemscope elements for {url}', [
'count' => count($recipes), 'url' => UrlRedactionHelper::redact($url),
]);

$this->recipe = [
'@type' => 'Recipe',
'@context' => 'http://schema.org'
Expand Down
4 changes: 2 additions & 2 deletions lib/Helper/HtmlToDomParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,13 @@ public function loadHtmlString(DOMDocument $dom, string $url, string $html): DOM
try {
$this->checkXMLErrors($errors, $url);
} catch (Exception $ex) {
throw new ImportException($this->l->t('Parsing of HTML failed.'), null, $ex);
throw new ImportException($this->l->t('Parsing of HTML failed.'), null, $ex, step: 'parse_html');
}

libxml_clear_errors();

if (!$parsedSuccessfully) {
throw new ImportException($this->l->t('Parsing of HTML failed.'));
throw new ImportException($this->l->t('Parsing of HTML failed.'), step: 'parse_html');
}
} finally {
libxml_use_internal_errors($libxml_previous_state);
Expand Down
25 changes: 25 additions & 0 deletions lib/Helper/UrlRedactionHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

// SPDX-FileCopyrightText: 2026 Nextcloud cookbook contributors
//
// SPDX-License-Identifier: AGPL-3.0-only OR AGPL-3.0-or-later

namespace OCA\Cookbook\Helper;

/**
* Strips credentials, query and fragment from URLs before they are logged.
*/
class UrlRedactionHelper {
public static function redact(?string $url): string {
$parts = parse_url($url ?? '');

if ($parts === false || !isset($parts['host'])) {
return '[invalid URL]';
}

$scheme = isset($parts['scheme']) ? $parts['scheme'] . '://' : '';
$port = isset($parts['port']) ? ':' . $parts['port'] : '';

return $scheme . $parts['host'] . $port . ($parts['path'] ?? '');
}
}
17 changes: 13 additions & 4 deletions lib/Service/HtmlDownloadService.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use OCA\Cookbook\Helper\HTMLFilter\HtmlEncodingFilter;
use OCA\Cookbook\Helper\HTMLFilter\HtmlEntityDecodeFilter;
use OCA\Cookbook\Helper\HtmlToDomParser;
use OCA\Cookbook\Helper\UrlRedactionHelper;
use OCP\IL10N;
use Psr\Log\LoggerInterface;

Expand Down Expand Up @@ -86,9 +87,17 @@ public function __construct(
* @throws ImportException If obtaining of the URL was not possible
*/
public function downloadRecipe(string $url): int {
$this->logger->info('Starting recipe download from {url}', ['url' => UrlRedactionHelper::redact($url)]);

$html = $this->fetchHtmlPage($url);

// Filter the HTML code
$this->logger->debug('Downloaded {url}: HTTP {status}, content-type {ct}, size {size} bytes', [
'url' => UrlRedactionHelper::redact($url),
'status' => $this->downloadHelper->getStatus(),
'ct' => $this->downloadHelper->getContentType() ?? 'unknown',
'size' => strlen($html),
]);

/** @var AbstractHtmlFilter $filter */
foreach ($this->htmlFilters as $filter) {
$filter->apply($html);
Expand Down Expand Up @@ -121,7 +130,7 @@ private function fetchHtmlPage(string $url): string {
$host = parse_url($url);

if (!$host) {
throw new ImportException($this->l->t('Could not parse URL'));
throw new ImportException($this->l->t('Could not parse URL'), step: 'download');
}

$opt = [
Expand Down Expand Up @@ -150,13 +159,13 @@ private function fetchHtmlPage(string $url): string {
try {
$this->downloadHelper->downloadFile($url, $opt, $headers);
} catch (NoDownloadWasCarriedOutException $ex) {
throw new ImportException($this->l->t('Exception while downloading recipe from %s.: %s', [$url, $ex->getMessage()]), 0, $ex);
throw new ImportException($this->l->t('Exception while downloading recipe from %s.: %s', [$url, $ex->getMessage()]), 0, $ex, step: 'download');
}

$status = $this->downloadHelper->getStatus();

if ($status < 200 || $status >= 300) {
throw new ImportException($this->l->t('Download from %s failed as HTTP status code %d is not in expected range.', [$url, $status]));
throw new ImportException($this->l->t('Download from %s failed as HTTP status code %d is not in expected range.', [$url, $status]), step: 'download');
}

$html = $this->downloadHelper->getContent();
Expand Down
19 changes: 17 additions & 2 deletions lib/Service/RecipeExtractionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
use OCA\Cookbook\Exception\HtmlParsingException;
use OCA\Cookbook\Helper\HTMLParser\HttpJsonLdParser;
use OCA\Cookbook\Helper\HTMLParser\HttpMicrodataParser;
use OCA\Cookbook\Helper\UrlRedactionHelper;
use OCP\IL10N;
use Psr\Log\LoggerInterface;

class RecipeExtractionService {
/**
Expand All @@ -22,10 +24,13 @@ class RecipeExtractionService {
*/
private $l;

private LoggerInterface $logger;

public function __construct(HttpJsonLdParser $jsonParser, HttpMicrodataParser $microdataParser,
IL10N $l10n) {
IL10N $l10n, LoggerInterface $logger) {
$this->parsers = [$jsonParser, $microdataParser];
$this->l = $l10n;
$this->logger = $logger;
}

/**
Expand All @@ -37,14 +42,24 @@ public function __construct(HttpJsonLdParser $jsonParser, HttpMicrodataParser $m
* @return array The data as returned from the parser
*/
public function parse(\DOMDocument $document, ?string $url): array {
$failures = [];

foreach ($this->parsers as $parser) {
try {
return $parser->parse($document, $url);
} catch (HtmlParsingException $ex) {
// Silently ignore failure as there might be other parsers better suited
$parserName = (new \ReflectionClass($parser))->getShortName();
$failures[] = "$parserName: {$ex->getMessage()}";
$this->logger->info('Parser {parser} failed for URL {url}: {reason}', [
'parser' => $parserName, 'url' => UrlRedactionHelper::redact($url), 'reason' => $ex->getMessage(),
]);
}
}

$detail = implode('; ', $failures);
$this->logger->warning('All parsers failed for URL {url}. Details: {detail}', [
'url' => UrlRedactionHelper::redact($url), 'detail' => $detail,
]);
throw new HtmlParsingException($this->l->t('No parser found for the given import.'));
}
}
9 changes: 7 additions & 2 deletions lib/Service/RecipeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use OCA\Cookbook\Helper\Filter\JSON\JSONFilter;
use OCA\Cookbook\Helper\ImageService\ImageConversionHelper;
use OCA\Cookbook\Helper\ImageService\ImageSize;
use OCA\Cookbook\Helper\UrlRedactionHelper;
use OCA\Cookbook\Helper\UserConfigHelper;
use OCA\Cookbook\Helper\UserFolderHelper;
use OCP\AppFramework\Db\DoesNotExistException;
Expand Down Expand Up @@ -350,15 +351,19 @@
$json = $this->recipeExtractionService->parse($this->htmlDownloadService->getDom(), $url);
$importedHtml = $this->htmlDownloadService->getDom()->saveHTML();
} catch (HtmlParsingException $ex) {
throw new ImportException($ex->getMessage(), null, $ex);
throw new ImportException($ex->getMessage(), null, $ex, step: 'extract_recipe');
}

$this->logger->info('Recipe extracted from {url}: name="{name}"', [
'url' => UrlRedactionHelper::redact($url), 'name' => $json['name'] ?? '(unnamed)',
]);

$json = $this->checkRecipe($json);

if (!$json) {
$this->logger->error('Importing parsers resulted in null recipe.'
. 'This is most probably a bug. Please report.');
throw new ImportException($this->il10n->t('No recipe data found. This is a bug'));
throw new ImportException($this->il10n->t('No recipe data found. This is a bug'), step: 'extract_recipe');
}

$json['url'] = $url;
Expand Down Expand Up @@ -605,7 +610,7 @@

$recipe_folder = $recipe_folders[0];

// TODO: Check that file is really an image

Check warning on line 613 in lib/Service/RecipeService.php

View workflow job for this annotation

GitHub Actions / Check for added todo messages

Found TODO: Check that file is really an image
switch ($size) {
case 'full':
return $this->imageService->getImageAsFile($recipe_folder);
Expand Down
11 changes: 8 additions & 3 deletions tests/Unit/Helper/HTMLParser/HttpJsonLdParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use OCA\Cookbook\Service\JsonService;
use OCP\IL10N;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;

/**
* @coversDefaultClass \OCA\Cookbook\Helper\HTMLParser\HttpJsonLdParser
Expand Down Expand Up @@ -49,7 +50,9 @@ public function testConstructor(): void {
*/
$l = $this->createStub(IL10N::class);

$parser = new HttpJsonLdParser($l, $jsonService);
$logger = $this->createStub(LoggerInterface::class);

$parser = new HttpJsonLdParser($l, $logger, $jsonService);

$lProperty = new \ReflectionProperty(HttpJsonLdParser::class, 'l');
$lProperty->setAccessible(true);
Expand All @@ -71,7 +74,9 @@ public function testHTMLFile($file, $valid, $jsonFile): void {
*/
$l = $this->createStub(IL10N::class);

$parser = new HttpJsonLdParser($l, $jsonService);
$logger = $this->createStub(LoggerInterface::class);

$parser = new HttpJsonLdParser($l, $logger, $jsonService);

$content = file_get_contents(__DIR__ . "/res_JsonLd/$file");

Expand Down Expand Up @@ -113,7 +118,7 @@ public static function dataProviderImageReference(): array {
* @covers ::parse
*/
public function testImageReference(array $input, array $expected): void {
$parser = new HttpJsonLdParser($this->createStub(IL10N::class), new JsonService());
$parser = new HttpJsonLdParser($this->createStub(IL10N::class), $this->createStub(LoggerInterface::class), new JsonService());

$document = new \DOMDocument();
$document->loadHTML('<html><head><script type="application/ld+json">' . json_encode($input) . '</script></head></html>');
Expand Down
Loading