Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions boot.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,26 @@

if (rex::isBackend() && rex::getUser() !== null) {
rex_view::addCssFile($addon->getAssetsUrl('styles.css'));

// Extend YRewrite forward list to show redirect source
if (\rex_addon::get('yrewrite')->isAvailable()) {
rex_extension::register('YREWRITE_FORWARD_LIST', function (rex_extension_point $ep) {
/** @var rex_list $list */
$list = $ep->getSubject();

// Add column to show if redirect was created by URL addon
$list->addColumn('url_addon_source', '<i class="rex-icon fa-link"></i>', count($list->getColumnNames()));
$list->setColumnLabel('url_addon_source', rex_i18n::msg('url_generator_redirect_source'));
$list->setColumnFormat('url_addon_source', 'custom', function ($params) {
$list = $params['list'];
$isUrlAddon = $list->getValue('is_url_addon');
if ($isUrlAddon === 1) {
return '<span class="label label-info">' . rex_i18n::msg('url_generator_redirect_from_url_addon') . '</span>';
}
return '<span class="label label-default">' . rex_i18n::msg('url_generator_redirect_manual') . '</span>';
});
});
}
}

if (null !== Url::getRewriter() && Url::getRewriter()->getSeoTagsExtensionPoint() !== '') {
Expand Down
9 changes: 9 additions & 0 deletions install.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,12 @@
$updateSql->update();
}
}

// Add is_url_addon column to yrewrite_redirect table if it doesn't exist
if (\rex_addon::get('yrewrite')->isAvailable()) {
\rex_sql_table::get(
\rex::getTable('yrewrite_redirect')
)
->ensureColumn(new \rex_sql_column('is_url_addon', 'TINYINT(1)', false, '0'))
->ensure();
}
5 changes: 5 additions & 0 deletions lang/de_de.lang
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,8 @@ url.profile.not_set = keine Auswahl
// Neue Update-Seite

url_generator_update = Neue Version verfügbar

# Redirect management
url_generator_redirect_source = Quelle
url_generator_redirect_from_url_addon = URL-AddOn
url_generator_redirect_manual = Manuell
5 changes: 5 additions & 0 deletions lang/en_gb.lang
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,8 @@ url_yform_value_description = Adds a link to the frontend generated by the URL a

// Löschen
url_generate_notice_url_param_key = When using rex_getUrl you no longer have to add the article Id from the chosen article above. So instead of <br /><code>rex_getUrl(4, '', ['id' => 5])</code> you can use <code>rex_getUrl('', '', ['news-id' => 5])</code>

# Redirect management
url_generator_redirect_source = Source
url_generator_redirect_from_url_addon = URL Addon
url_generator_redirect_manual = Manual
5 changes: 5 additions & 0 deletions lib/Url/ExtensionPointManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ public function getStructureClangId(): int|string|null
return $this->structureClangId;
}

public function isDatasetEditMode(): bool
{
return $this->dataEditMode ?? false;
}

protected function normalize(): void
{
switch ($this->extensionPoint->getName()) {
Expand Down
89 changes: 89 additions & 0 deletions lib/Url/Generator.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,20 @@ public function execute(): void
$profiles = Profile::getByTableName($this->manager->getDatasetTableName());
if (count($profiles) > 0) {
foreach ($profiles as $profile) {
// Get old URLs before deletion to create redirects (only in edit mode)
$oldUrls = [];
if ($this->manager->isDatasetEditMode()) {
$oldUrls = UrlManagerSql::getOriginUrls($profile->getId(), $this->manager->getDatasetPrimaryId());
}

$profile->deleteUrlsByDatasetId($this->manager->getDatasetPrimaryId());
$profile->buildUrlsByDatasetId($this->manager->getDatasetPrimaryId());

// Create redirects from old to new URLs (only if we had old URLs)
if (!empty($oldUrls)) {
$newUrls = UrlManagerSql::getOriginUrls($profile->getId(), $this->manager->getDatasetPrimaryId());
self::createRedirectsForUrlChanges($oldUrls, $newUrls);
}
}
}
break;
Expand All @@ -71,4 +83,81 @@ public static function boot(): void
}
}
}

/**
* Creates redirects when URLs change
*
* @param array $oldUrls Old URL entries before change
* @param array $newUrls New URL entries after change
*/
private static function createRedirectsForUrlChanges(array $oldUrls, array $newUrls): void
{
if (empty($oldUrls) || empty($newUrls)) {
return;
}

// Group by clang_id to match old and new URLs properly
$oldUrlsByClang = [];
foreach ($oldUrls as $oldUrl) {
$clangId = $oldUrl['clang_id'];
if (!isset($oldUrlsByClang[$clangId])) {
$oldUrlsByClang[$clangId] = [];
}
$oldUrlsByClang[$clangId][] = $oldUrl;
}

$newUrlsByClang = [];
foreach ($newUrls as $newUrl) {
$clangId = $newUrl['clang_id'];
if (!isset($newUrlsByClang[$clangId])) {
$newUrlsByClang[$clangId] = [];
}
$newUrlsByClang[$clangId][] = $newUrl;
}

// Create redirects for each language
foreach ($oldUrlsByClang as $clangId => $oldClangUrls) {
if (!isset($newUrlsByClang[$clangId])) {
continue;
}

$newClangUrls = $newUrlsByClang[$clangId];

// Match origin URLs (not user_path, not structure)
$oldOriginUrl = null;
$newOriginUrl = null;

foreach ($oldClangUrls as $url) {
if ($url['is_user_path'] === 0 && $url['is_structure'] === 0) {
$oldOriginUrl = $url['url'];
break;
}
}

foreach ($newClangUrls as $url) {
if ($url['is_user_path'] === 0 && $url['is_structure'] === 0) {
$newOriginUrl = $url['url'];
break;
}
}

if ($oldOriginUrl && $newOriginUrl && $oldOriginUrl !== $newOriginUrl) {
// Get domain ID for the article
$articleId = null;
if (!empty($newClangUrls)) {
$articleId = $newClangUrls[0]['article_id'] ?? null;
}
$domainId = 1; // default

if ($articleId && \rex_addon::get('yrewrite')->isAvailable()) {
$domain = \rex_yrewrite::getDomainByArticleId($articleId, $clangId);
if ($domain) {
$domainId = $domain->getId();
}
}

RedirectManager::createRedirect($oldOriginUrl, $newOriginUrl, $domainId);
}
}
}
}
141 changes: 141 additions & 0 deletions lib/Url/RedirectManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php

/**
* This file is part of the Url package.
*
* @author (c) Thomas Blum <thomas@addoff.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Url;

class RedirectManager
{
/**
* Creates a 301 redirect from old URL to new URL in yrewrite_redirect table
*
* @param string $sourceUrl The old URL to redirect from
* @param string $targetUrl The new URL to redirect to
* @param int $domainId The yrewrite domain ID (required, no default)
* @return bool True if redirect was created successfully
*/
public static function createRedirect(string $sourceUrl, string $targetUrl, int $domainId): bool
{
if (!\rex_addon::get('yrewrite')->isAvailable()) {
return false;
}

// Validate URLs are non-empty
if (empty($sourceUrl) || empty($targetUrl)) {
return false;
}

// Don't create redirect if source and target are the same
if ($sourceUrl === $targetUrl) {
return false;
}

// Remove any existing redirect that would create a loop
// If the new target URL was previously a source URL, delete it
self::deleteRedirectBySource($targetUrl);

// Check if redirect already exists
$sql = \rex_sql::factory();
$existing = $sql->getArray(
'SELECT id FROM ' . \rex::getTable('yrewrite_redirect') .
' WHERE url_source = ? AND domain_id = ?',
[$sourceUrl, $domainId]
);

if (count($existing) > 0) {
// Update existing redirect
$sql->setTable(\rex::getTable('yrewrite_redirect'));
$sql->setWhere('id = ?', [$existing[0]['id']]);
$sql->setValue('url_target', $targetUrl);
$sql->setValue('status', 301);
$sql->setValue('is_url_addon', 1);
try {
$sql->update();
self::clearYrewriteCache();
return true;
} catch (\rex_sql_exception $e) {
\rex_logger::logException($e);
return false;
}
}

// Create new redirect
$sql = \rex_sql::factory();
$sql->setTable(\rex::getTable('yrewrite_redirect'));
$sql->setValue('domain_id', $domainId);
$sql->setValue('url_source', $sourceUrl);
$sql->setValue('url_target', $targetUrl);
$sql->setValue('status', 301);
$sql->setValue('type', 'url');
$sql->setValue('is_url_addon', 1);

try {
$sql->insert();
self::clearYrewriteCache();
return true;
} catch (\rex_sql_exception $e) {
\rex_logger::logException($e);
return false;
}
Comment on lines +45 to +86

Copilot AI Nov 16, 2025

Copy link

Choose a reason for hiding this comment

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

Potential race condition: Between the check for existing redirects (lines 40-45) and the update/insert operation (lines 48-60 or 64-79), another process could create/modify/delete the same redirect entry. This could lead to unexpected behavior in concurrent scenarios. Consider using database transactions or a single UPSERT query (INSERT ... ON DUPLICATE KEY UPDATE) to make this operation atomic.

Copilot uses AI. Check for mistakes.
Comment on lines +59 to +86

Copilot AI Nov 16, 2025

Copy link

Choose a reason for hiding this comment

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

Silent error handling: When rex_sql_exception is caught (lines 54-60, 73-79, 98-104), the method returns false but doesn't log the exception or provide any diagnostic information. This makes debugging difficult in production. Consider logging the exception message using rex_logger::logException($e) before returning false.

Copilot uses AI. Check for mistakes.
}

/**
* Deletes a redirect by its source URL to prevent loops
*
* @param string $sourceUrl The source URL of the redirect to delete
* @return bool True if redirect was deleted or didn't exist
*/
public static function deleteRedirectBySource(string $sourceUrl): bool
{
if (!\rex_addon::get('yrewrite')->isAvailable()) {
return false;
}

$sql = \rex_sql::factory();
$sql->setTable(\rex::getTable('yrewrite_redirect'));
$sql->setWhere('url_source = ? AND is_url_addon = 1', [$sourceUrl]);

try {
$sql->delete();
self::clearYrewriteCache();
return true;
} catch (\rex_sql_exception $e) {
Comment thread
AWqxKAWERbXo marked this conversation as resolved.
\rex_logger::logException($e);
return false;
}
}

/**
* Clears the YRewrite redirect cache
*/
private static function clearYrewriteCache(): void
{
if (class_exists('\rex_yrewrite_forward')) {
\rex_yrewrite_forward::clearCache();
}
}

/**
* Gets all redirects created by the URL addon
*
* @return array Array of redirects
*/
public static function getUrlAddonRedirects(): array
{
if (!\rex_addon::get('yrewrite')->isAvailable()) {
return [];
}

$sql = \rex_sql::factory();
return $sql->getArray(
'SELECT * FROM ' . \rex::getTable('yrewrite_redirect') . ' WHERE is_url_addon = 1'
);
}
}
16 changes: 16 additions & 0 deletions lib/Url/UrlManagerSql.php
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,22 @@ public static function getOriginAndExpanded(Profile $profile, int $datasetId, in
return $sql->sql->getArray('SELECT * FROM '.\rex::getTable(self::TABLE_NAME).' WHERE `profile_id` = ? AND `data_id` = ? AND `clang_id` = ?', [$profile->getId(), $datasetId, $clangId]);
}

/**
* Get all URLs for a specific profile and dataset (across all languages)
*
* @param int $profileId
* @param int $datasetId
*
* @throws \rex_sql_exception
*
* @return array
*/
public static function getOriginUrls(int $profileId, int $datasetId): array
{
$sql = self::factory();
return $sql->sql->getArray('SELECT * FROM '.\rex::getTable(self::TABLE_NAME).' WHERE `profile_id` = ? AND `data_id` = ?', [$profileId, $datasetId]);
}
Comment on lines +284 to +298

Copilot AI Nov 16, 2025

Copy link

Choose a reason for hiding this comment

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

The PHPDoc comment should document that this method can throw \rex_sql_exception for consistency with other methods in this class (see getOrigin() and getOriginAndExpanded() above).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied in commit bbbe9a6. The PHPDoc for getOriginUrls() already included the @throws \rex_sql_exception annotation, which matches the pattern used in other methods in the class. Additionally, I've addressed all other code review feedback including strict type comparisons, error logging, and input validation.


/**
* @param Url $url
*
Expand Down