Creating canonical URLs with custom route-classes - php

I'm trying to implement canonical URLs and combine it with custom route-classes.
The URL-scheme is something like this:
/category-x/article/123
/category-y/article/123
I create a custom route-class extending Zend_Controller_Router_Route_Regex and checks that the article 123 exists and that the URL includes the correct category-name. If article 123 belongs in category-x and the user is accessing category-y I want to redirect to the correct URL.
But the routes does not have any obvious possibility to do this directly. What's the best practice approach here?

I often do this in my action controller. Something like this...
// assuming GET /category-y/article/123
// $article->url is generated, and contains /category-x/article/123
if (this->_request->getRequestUri() != $article->url) {
return $this->_helper->redirector->goToUrl($article->url);
}
In this example, $article->url would need to be generated from your database data. I often use this to verify a correct slug, when I also pull in the object id.
You could also potentially move this to your routing class, if you wanted to use a custom one instead of using Regex (you could subclass it).

I ended up with this solution:
The custom route-class creates the canonical URL in its match()-method like this:
public function match($path, $partial = false) {
$match = parent::match($path, $partial);
if (!empty($match)) {
$article = $this->backend->getArticle($match['articleId']);
if (!$article) {
throw new Zend_Controller_Router_Exception('Article does not exist', 404);
}
$match['canonicalUrl'] = $this->assemble(array(
'title' => $article->getTitle(),
'articleId' => $article->getId()
));
}
return $match;
}
$article is populated inside match() if the parent::match() returns array.
I've created a front controller plugin which hooks on the routeShutdown() like this:
public function routeShutdown(Zend_Controller_Request_Abstract $request) {
if ($request->has('canonicalUrl')){
$canonicalUrl = $request->getBaseUrl() . '/' . $request->get('canonicalUrl');
if ($canonicalUrl != $request->getRequestUri()) {
$this->getResponse()->setRedirect($canonicalUrl, 301);
}
}
}
It simply checks if the route(custom or native Zend) created a canonical URL and if the requested URL does not match, redirect to the correct canonical URL.

Related

SilverStripe 3.1+ Using PHP to dynamically change the URL slug of a Redirector

This is an extension of my original question SilverStripe 3.1+ Dynamically creating page redirects
I have a product page URL
a) www.mysite.com/category/subcat/productid
You can visit this page via a a separate redirector page
b) www.mysite.com/productid
Page 'a' has tabs which can be visited via
c) www.mysite.com/category/subcat/productid/tabid
I would like to use PHP to dynamically create links / redirectors for each product page create so it can be visited by:
1) A short URL using only its 'predicted' shown in 'b' (I can do this by creating a page redirector but this is long winded for a large number of products.
2) Create a short URL for each tab link as well, so 'c' would be redirected from: d) www.mysite.com/productid/tabid
The 'tabid' can be hard coded and in my case would be: audio, videos, pictures, firmware
Currently using the code in my ProductPage.php
class ProductPage_Controller extends Page_Controller {
private static $allowed_actions = array(
'audio',
'pictures',
'firmware',
'videos',
);
public function audio() {
$this->redirect($this->Link().'?tab=audio');
}
public function pictures() {
$this->redirect($this->Link().'?tab=pictures');
}
public function firmware() {
$this->redirect($this->Link().'?tab=firmware');
}
public function videos() {
$this->redirect($this->Link().'?tab=videos');
}
Allows me to go from /video to /?tab=video but this of course does not shorten the URL which is the final result I'm after.
Possibly this could be done in an extension of the RedirectorPage.php?
One way to do this is to use onBeforeHTTPError404 to hook into when a 404 error is called and to redirect any product found.
We create a ProductRedirectExtension with an onBeforeHTTPError404 function. This function will get called when a page cannot be found, but before the system returns a 404 error. The code in here will check if a ProductPage exists with a URLSegment with the first part of user's URL string. If a ProductPage is found we then check if the second part of the user's URL string is one of the tab keywords. After that the user is redirected to the page.
ProductRedirectExtension.php
class ProductRedirectExtension extends Extension {
public function onBeforeHTTPError404( $request ) {
$urlSegment = $request->param('URLSegment');
$action = strtolower($request->param('Action'));
$page = ProductPage::get()->filter('URLSegment', $urlSegment)->first();
if ($page) {
$link = $page->Link();
if ($action == 'audio' || $action == 'pictures' || $action == 'firmware' || $action == 'videos') {
$link .= '?tab=' . $action;
}
$response = new SS_HTTPResponse();
$response->redirect($link, 301);
throw new SS_HTTPResponse_Exception($response);
}
}
}
We enable this extension with the following config settings.
config.yml
RequestHandler:
extensions:
- ProductRedirectExtension
ContentController:
extensions:
- ProductRedirectExtension
ModelAsController:
extensions:
- ProductRedirectExtension
The shortest you could go is www.mysite.com/product/productid/tabid. It is possible to do this without the product part, but this would make your life a lot harder as you would have to go through a lot of trouble to still have access to Admin and Dev for example.
If you want the shortest URL you should make a new Page, as your current ProductPage is a child of another page, telling from your URL:
www.mysite.com/category/subcat/productid
You can achieve the best scenario with this code:
class ProductRedirectPage_Controller extends Page_Controller
{
private static $allowed_actions = [
'Product'
];
private static $url_handlers = [
'$ID/$TabID' => 'Product'
];
public function Product()
{
// Get your ProductPage, this should work if there is only one ProductPage
$page = ProductPage::get()->first();
if(! $productId = $this->request->param('ID'))
return $this->redirect($page->Link()); // or send them somewhere else
$tabId = $this->request->param('TabID');
$link = $page->Link('yourAction/' . $productId . '/' . $tabId);
return $this->redirect($link);
}
}
EDIT
With this code, when www.mysite.com/product/productid/tabid is visited, it will redirect you to where your ProductPage is. Maybe you wish to change the /$tabId to ?tabId=' . $tabId, but that is totally up to you.
So if I'm to summarise, you want newpage/video to open the correct on page tab, rather than being an action to render a different page?
You could do something funky with private static $url_handlers, to set a parameter you could query through $request->param('Thing').
Or you could set up handleAction to test whether or not $action either is one of the things in the list (and set flags for later use in eg. a template), or otherwise parent::handleAction.
This answer isn't extremely fleshed out, but should hopefully provide you some ideas on directions to investigate for your own use case.

Codeigniter URL Routing - Capture Everything for Tree Structure

OK, I think I am probably (hopefully) going to be told I am going about this in the wrong way.
Currently, if I go to root of CI web I call a function which reads a predefined location with map_directory(). I then iterate it out in a view as a simple directory listing.
I then want to click on one of these directories to see what's inside. When I do that I call a different controller function called browse.
So if I click on one link I go to
www.mysite.com/dir1
(which is Routed to www.mysite.com/controller/browse/$1 - where $1 in this instance = dir1).
Now I am presented with a dir listing of dir1. I have configured the links of the displayed listings to now go to:
www.mysite.com/dir1/subdir1 etc.
What I want to do and this might be the bit where I am cheating/going wrong, is capture everything after
www.mysite.com/
and pass it to
www.mysite.com/controller/browser/$1
So example:
www.mysite.com/dir1/subdir1/ => www.mysite.com/controller/browse/"dir1/subdir1"
I know I can't have '"' in there, but that's the bit I am trying to pass to the map_directory() function...so it goes /.$var (where $var would = $1 = "dir1/subdir1".
So far I have tried in CI Routes.php:
$route['(.+)$'] = 'controller/browse/$1';
$route['([a-zA-Z0-9\/]*)'] = 'controller/browse/$1';
$route['(:any)'] = 'controller/browse/$1';
...but they all only ever seem to capture "dir1" never anything beyond that.
I hope that makes sense to someone....
You can just use the special _remap() method in your controller to override the default routing behavior:
class Controller extends CI_Controller
{
protected function browse($path)
{
// ... do something with $path here
}
public function _remap($method, $params = array())
{
if ($method === 'browse')
{
$params = implode('/', $params);
return $this->browse($params);
}
elseif (method_exists($this, $method))
{
return call_user_func_array(array($this, $method), $params);
}
show_404();
}
}

Codeigniter URL remapping strategy

I'm working on a project built in codeigniter that makes heavy use of routes and the remap function to rewrite urls. The current implementation is confusing and messy.
Essentially this is what the designer was trying to accomplish:
www.example.com/controller/method/arg1/
TO
www.example.com/arg1/controller/method/
Can anyone suggest a clean way of accomplishing this?
This actually only needs to happen for one specific controller. It's fine if all other controllers need to simply follow the normal /controller/model/arg1... pattern
Just to give you an idea of how the current code looks here is the 'routes' file: (not really looking into any insight into this code, just want to give you an idea of how cluttered this current setup is that I'm dealing with. I want to just throw this away and replace it with something better)
// we need to specify admin controller and functions so they are not treated as a contest
$route['admin/users'] = 'admin/users';
$route['admin/users/(:any)'] = 'admin/users/$1';
$route['admin'] = 'admin/index/';
$route['admin/(:any)'] = 'admin/$1';
// same goes for sessions and any other controllers
$route['session'] = 'session/index/';
$route['session/(:any)'] = 'session/$1';
// forward http://localhost/ball/contests to controller contests method index
$route['(:any)/contests'] = 'contests/index/$1';
// forward http://localhost/ball/contests/vote (example) to controller contests method $2 (variable)
$route['(:any)/contests/(:any)'] = 'contests/index/$1/$2';
// forward http://localhost/ball/contests/users/login (example) to controller users method $2 (variable)
$route['(:any)/users/(:any)'] = 'users/index/$1/$2';
// if in doubt forward to contests to see if its a contest
// this controller will 404 any invalid requests
$route['(:any)'] = 'contests/index/$1';
$route['testing/'] = 'testing/';
And the remap function that goes with it:
public function _remap($method, $params = array()){
// example $params = array('ball', 'vote')
// params[0] = 'ball', params[1] = 'vote'
/*
* Write a detailed explanation for why this method is used and that it's attempting to accomplish.
* Currently there is no documentation detailing what you're trying to accomplish with the url here.
* Explain how this moves the contest name url segment infront of the controller url segment. Also
* explain how this works with the routing class.
* */
$count = count($params);
if($count == 0){ // no contest specified
redirect('http://messageamp.com');
return;
}
$contest_name = $params[0];
unset($params[0]); //remove the contest name from params array because we are feeding this to codeigniter
if($count < 2) // no method specified
$method = 'index';
else{
$method = $params[1];
unset($params[1]);
}
//We need to scrap this, lazy-loading is a best-practice we should be following
$this->init(); //load models
//make sure contest is valid or 404 it
if(!$this->central->_check_contest($contest_name)){
show_404();
return;
}
$this->data['controller'] = 'contests';
$this->data['method'] = $method;
$this->data['params'] = $params;
// call the function if exists
if(method_exists($this, $method)){
return call_user_func_array(array($this, $method), $params);
}
show_404(); // this will only be reached if method doesn't exist
}
To get something like this:
www.example.com/controller/method/arg1/ TO www.example.com/arg1/controller/method/
You could do this in your routes.php config:
$route['(:any)/(:any)/(:any)'] = "$2/$3/$1";
However, if you want to have all of your other classes stick to the default routing, you would need to create routes for each of them to overwrite this default route:
$route['controller_name/(:any)'] = "controller_name/$1";

Is there a method to get the module/action a URL refers to?

In symfony, is there a method that I can use which does a reverse lookup on my routes to determine the module and action a URL points to?
Say, something like:
get_module("http://host/cars/list"); // ("cars")
get_action("http://host/frontend_dev.php/cars/list"); // ("list")
Bear in mind, I don't want to perform simple string-hacking to do this as there may be mappings that are not quite so obvious:
get_module("/"); // (What this returns is entirely dependent on the configured routes.)
Thanks!
Use the sfRouting class to match URLs. This is part of the sfContext object. Your code (in an action) would look like this:
public function executeSomeAction(sfWebRequest $request)
{
if($params = $this->getContext()->getRouting()->parse('/blog'))
{
$module = $params['module']; // blog
$action = $params['action']; // index
$route = $params['_sf_route'] // routing instance (for fun)
}
else
{
// your URL did not match
}
}

A Generic, catch-all action in Zend Framework... can it be done?

This situation arises from someone wanting to create their own "pages" in their web site without having to get into creating the corresponding actions.
So say they have a URL like mysite.com/index/books... they want to be able to create mysite.com/index/booksmore or mysite.com/index/pancakes but not have to create any actions in the index controller. They (a non-technical person who can do simple html) basically want to create a simple, static page without having to use an action.
Like there would be some generic action in the index controller that handles requests for a non-existent action. How do you do this or is it even possible?
edit: One problem with using __call is the lack of a view file. The lack of an action becomes moot but now you have to deal with the missing view file. The framework will throw an exception if it cannot find one (though if there were a way to get it to redirect to a 404 on a missing view file __call would be doable.)
Using the magic __call method works fine, all you have to do is check if the view file exists and throw the right exception (or do enything else) if not.
public function __call($methodName, $params)
{
// An action method is called
if ('Action' == substr($methodName, -6)) {
$action = substr($methodName, 0, -6);
// We want to render scripts in the index directory, right?
$script = 'index/' . $action . '.' . $this->viewSuffix;
// Script file does not exist, throw exception that will render /error/error.phtml in 404 context
if (false === $this->view->getScriptPath($script)) {
require_once 'Zend/Controller/Action/Exception.php';
throw new Zend_Controller_Action_Exception(
sprintf('Page "%s" does not exist.', $action), 404);
}
$this->renderScript($script);
}
// no action is called? Let the parent __call handle things.
else {
parent::__call($methodName, $params);
}
}
You have to play with the router
http://framework.zend.com/manual/en/zend.controller.router.html
I think you can specify a wildcard to catch every action on a specific module (the default one to reduce the url) and define an action that will take care of render the view according to the url (or even action called)
new Zend_Controller_Router_Route('index/*',
array('controller' => 'index', 'action' => 'custom', 'module'=>'index')
in you customAction function just retrieve the params and display the right block.
I haven't tried so you might have to hack the code a little bit
If you want to use gabriel1836's _call() method you should be able to disable the layout and view and then render whatever you want.
$this->_helper->layout()->disableLayout();
$this->_helper->viewRenderer->setNoRender(true);
I needed to have existing module/controller/actions working as normal in a Zend Framework app, but then have a catchall route that sent anything unknown to a PageController that could pick user specified urls out of a database table and display the page. I didn't want to have a controller name in front of the user specified urls. I wanted /my/custom/url not /page/my/custom/url to go via the PageController. So none of the above solutions worked for me.
I ended up extending Zend_Controller_Router_Route_Module: using almost all the default behaviour, and just tweaking the controller name a little so if the controller file exists, we route to it as normal. If it does not exist then the url must be a weird custom one, so it gets sent to the PageController with the whole url intact as a parameter.
class UDC_Controller_Router_Route_Catchall extends Zend_Controller_Router_Route_Module
{
private $_catchallController = 'page';
private $_catchallAction = 'index';
private $_paramName = 'name';
//-------------------------------------------------------------------------
/*! \brief takes most of the default behaviour from Zend_Controller_Router_Route_Module
with the following changes:
- if the path includes a valid module, then use it
- if the path includes a valid controller (file_exists) then use that
- otherwise use the catchall
*/
public function match($path, $partial = false)
{
$this->_setRequestKeys();
$values = array();
$params = array();
if (!$partial) {
$path = trim($path, self::URI_DELIMITER);
} else {
$matchedPath = $path;
}
if ($path != '') {
$path = explode(self::URI_DELIMITER, $path);
if ($this->_dispatcher && $this->_dispatcher->isValidModule($path[0])) {
$values[$this->_moduleKey] = array_shift($path);
$this->_moduleValid = true;
}
if (count($path) && !empty($path[0])) {
$module = $this->_moduleValid ? $values[$this->_moduleKey] : $this->_defaults[$this->_moduleKey];
$file = $this->_dispatcher->getControllerDirectory( $module ) . '/' . $this->_dispatcher->formatControllerName( $path[0] ) . '.php';
if (file_exists( $file ))
{
$values[$this->_controllerKey] = array_shift($path);
}
else
{
$values[$this->_controllerKey] = $this->_catchallController;
$values[$this->_actionKey] = $this->_catchallAction;
$params[$this->_paramName] = join( self::URI_DELIMITER, $path );
$path = array();
}
}
if (count($path) && !empty($path[0])) {
$values[$this->_actionKey] = array_shift($path);
}
if ($numSegs = count($path)) {
for ($i = 0; $i < $numSegs; $i = $i + 2) {
$key = urldecode($path[$i]);
$val = isset($path[$i + 1]) ? urldecode($path[$i + 1]) : null;
$params[$key] = (isset($params[$key]) ? (array_merge((array) $params[$key], array($val))): $val);
}
}
}
if ($partial) {
$this->setMatchedPath($matchedPath);
}
$this->_values = $values + $params;
return $this->_values + $this->_defaults;
}
}
So my MemberController will work fine as /member/login, /member/preferences etc, and other controllers can be added at will. The ErrorController is still needed: it catches invalid actions on existing controllers.
I implemented a catch-all by overriding the dispatch method and handling the exception that is thrown when the action is not found:
public function dispatch($action)
{
try {
parent::dispatch($action);
}
catch (Zend_Controller_Action_Exception $e) {
$uristub = $this->getRequest()->getActionName();
$this->getRequest()->setActionName('index');
$this->getRequest()->setParam('uristub', $uristub);
parent::dispatch('indexAction');
}
}
You could use the magic __call() function. For example:
public function __call($name, $arguments)
{
// Render Simple HTML View
}
stunti's suggestion was the way I went with this. My particular solution is as follows (this uses indexAction() of whichever controller you specify. In my case every action was using indexAction and pulling content from a database based on the url):
Get an instance of the router (everything is in your bootstrap file, btw):
$router = $frontController->getRouter();
Create the custom route:
$router->addRoute('controllername', new Zend_Controller_Router_Route('controllername/*', array('controller'=>'controllername')));
Pass the new route to the front controller:
$frontController->setRouter($router);
I did not go with gabriel's __call method (which does work for missing methods as long as you don't need a view file) because that still throws an error about the missing corresponding view file.
For future reference, building on gabriel1836 & ejunker's thoughts, I dug up an option that gets more to the point (and upholds the MVC paradigm). Besides, it makes more sense to read "use specialized view" than "don't use any view".
// 1. Catch & process overloaded actions.
public function __call($name, $arguments)
{
// 2. Provide an appropriate renderer.
$this->_helper->viewRenderer->setRender('overload');
// 3. Bonus: give your view script a clue about what "action" was requested.
$this->view->action = $this->getFrontController()->getRequest()->getActionName();
}
#Steve as above - your solution sounds ideal for me but I am unsure how you implmeented it in the bootstrap?

Categories