Sean Reiser

Hi I'm Seán Reiser, this is my Personal Blog

“A person is what they think about all day long”
~Ralph Waldo Emerson

Using ChatGPT to Audit and Migrate Drupal Modules

 

This was fun! I just inherited a new Drupal 9 site.  Whenever I get a new site I do a code review of all the custom modules for security purposes.  Additionally,  since I’ll eventually need to upgrade the site to Drupal 10, I figured I’d assess the code for D10 readiness.  There were 16 custom modules.

As an experiment I fired up ChatGPT and used the following prompt:

Review the following Drupal Module to ensure it followings best practices, is secure, efficient and is compatible with Drupal 9 and 10.

CharGPT found 90% of the issues I would’ve flagged.  The items it didn’t find that I didn’t find were stylistic, not things that I’d consider crucial or security related.  Then I entered this simple prompt:

Please implement these suggestions.

CharGPT updated the code.  I received the code, ran it in stage and will implement it over the weekend in my next release window.

I find using ChatGPT instead of Drupal Rector for removing deprecations interesting because I can ask it questions and do an automated code audit,  This probably saved me 80% of the time and is less prone to error and typos.

Configuring Drupal 9 to use the SMTP module with iCloud+'s custom domains

When Google announced it was going to shut down its legacy free email for domains, I decided to  use iCloud+ to manage the mail from seanreiser.com since I was paying for it anyway. I set it up but it's been working fine as a mail server. I had been running sendmail off my web server to send mail from my site when I needed to. In order to avoid spam folders,I decided to move to using SMTP, which meant I needed to authenticate to Apple's SMTP Server.

At first, I had a problem authenticating, until I realized that your email addresses at your custom domain is an aliase, so you need to authenticate with your iCloud account and an App-Specific Password. I'm using Drupal 9;s SMTP Authentication Support  Module.  Here are the parameters I used on /admin/config/system/smtp:

SMTP server settings
  • Enable TLS encryption automatically - On
  • Turn on the SMTP keep alive feature - Off
SMTP Authentication
  • App-Specific Password.
E-mail options
  • Allow to send e-mails formatted as HTML - up to your use case

Everything else leave as is. I'd do a test with debugging turned on and then turn it off. I also used the mail system module to redirect all mail to SMTP.

Good luck, Share and  enjoy!

 

 

Image
Laptop w/ Stickers

Converting File Entities To Media Entities in Drupal 8 and 9

So I have a personal Drupal  site which has is currently on Drupal 9.  It’s had a long history on Drupal dating back to Drupal 4. Like many Drupalists, my personal site is my background project as well as the place I experiment on things.  It’s also the first site I migrate to new versions of Drupal.

Round Tuit

The site had a photo content type with a file entity field and a couple of taxonomy reference fields.  It contains photographs I've taken.  In a modern D9 site this content type would be a media bundle.  But, since I migrated the D7 site to D8 early in D8’s life,  the media module wasn’t ready for prime time, and I left them as nodes instead of migrating them to media entities.  Since I got my hands on a Round Tuit, I decided to finally address this.

My goal is to create a media bundle which I'll call "photography", with the images and taxonomy attached.  In olden times I was lazy about adding alt tags to my images, so I want to populate empty alt tags with the image title (not perfect but better than nothing).   For now I'll attach those media entities back to the original node, with the goal of deleting the nodes once I'm comfortable that everything converted a-ok. 

So, I see 3 possible options to accomplish this:

  1. Drupal Contrib Modules
  2. MigrateAPI
  3. Write a custom module

Going through contrib I found the Migrate File Entities to Media Entities Module.  This module looks good for the average use case.  It creates fields and  migration scripts to do the migration. One feature on the module is duplicate checking.  The module uploads your file to a 3rd party service to generate a hash,  I don't need that for my photography, I know there are no duplicates.  If I were to use this, I'd also need to modify the migration YAML to add Taxonomy.  All this together made it so this was not the best solution for me.

So my choices how were either the MigrateAPI or write a module.  When the data source and data target are the same database I prefer writing a module, but I could've used migrate as well. Here's what I did:

The site has a Content Type called "photo"  The 4 fields I want to migrate are:

  • Title - a standard Drupal node title
  • field_photo - an image field containing the photo
  • field_photo_category : a taxonomy reference field
  • field_photo_tag : a taxonomy reference field

I created a Media Bundle called "photography" (photography sounds more serious than "photo".with corresponding fields:

  • Title
  • field_media_image
  • field_photo_category
  • field_photo_tag

I also created a field "field_photo_media" on the photo content type to store the reference to the newly created media entity.

Every site I build has a sitename_db_maint module which I use to for these little utilities that come up.  It's generally disabled and I'll enable it to run the utility, and disable it when I'm done.  I could've executed this through an update hook but for something like this I like to execute it explicitly by hitting a URL. If I was planning on a run with a larger number of nodes or something that could be run by a lesstechnical person, I'd add drush support and probaby batches. So I used drush generate to generate scaffolding for a controller, build the route etc.

Method convertNode2Media()  is where the work gets done.  The code does an entity query pulling all of the nodes in that content type.  I loop through the nodes, creating the media entity, assigning the fields, saving the media entity and assign the reference  to the new media entity back to the node and save that.  

You'll notice that if I'm missing an alt tag or title tag from the source, i'm assigning the node's title to these fields.

I added some simple logging and exception catching.  and the thing worked.  After some auditing, I deleted the photo nodes and content type.

Standard Disclaimer:  Test your code, backup you're database, don't run on live data.



namespace Drupal\seanreiser_db_maint\Controller;

use \Drupal\Core\Controller\ControllerBase;
use \Drupal\file\FileInterface;
use \Drupal\media\Entity\Media;
use \Drupal\node\Entity\Node;

/**
 * Returns responses for Sean Reiser DB Maint routes.
 */
class SeanReiserDbMaintControllerNode2Media extends ControllerBase {

  /**
   * Builds the response.
   */
  public function build() {
  	$output = $this->convertNode2Media();

    $build['content'] = [
      '#type' => 'item',
      '#markup' => $this->t($output),
    ];

    return $build;
  }

  private function convertNode2Media(){
	 $cnt = 0;
	 $nids = \Drupal::entityQuery('node')->condition('type','photo')->execute();
	 $nodes = Node::loadMultiple($nids);

	 foreach ($nodes as $node){

		 try{
			 $categories = [];
			 foreach ($node->get('field_photo_category') as $category){
				$categories[] = $category->entity->id();
			 }

			 $tags = [];
			 foreach ($node->get('field_photo_tags') as $tag){
				$tags[] = $tag->entity->id();
			 }


			 $media_entity = Media::create([
			  	'bundle'            => 'photography',
			  	'uid'              	=> \Drupal::currentUser()->id(),
			  	'status' 			=> '1',
			  	'name'				=>  $node->get('title')->value,
			  	'field_media_image' => [
			  		'target_id' => $node->get('field_photo')->entity->id(),
			  		'alt' =>  !empty($file->alt) ? $file->alt : $node->get('title')->value,
			  		'title' =>  !empty($file->title) ? $file->title : $node->get('title')->value,
			  		],
				]);

			  $media_entity->set('field_photo_category', $categories);
			  $media_entity->set('field_photo_tags', $tags);

			  $media_entity->save();

			  $node->field_photo_media->entity = $media_entity;

			  $node->save();

			  \Drupal::logger('dbmaint-Node2Media')->notice(
				  'Node "@node" converted to Media Entity "@media" Count "@cnt"',
				  ['@node' => $node->id(), '@media' => $media_entity->id(), "@cnt" => $cnt]
			  );

			  $cnt ++;

		  }  catch (Exception $e) {
              \Drupal::logger('dbmaint-Node2Media')->error($e->getMessage());
          }

	  }

	  return $cnt.' nodes converted';

  }

}
 

 

 

 

Image
Laptop w/ Stickers

Creating a Bookmarklet To Save a Link With Metadata In Drupal 9

This site has a linkblog and I thought I'd do a quick writeup on how I capture the links and their metadata.  You'll notice the the links are displayed in cards, similar to what you see on social media sites such as Facebook and Twitter.

This writeup will cover:

  1. The conten type to store a link and its metadata,
  2. Creating a bookmarklet so you can easily add a story to your site as you surf the web.
  3. Scraping metadata on a webpage to get the image, site name, title and description.

This writeup assumes that you have some basic understanding of Drupal on a site builder level.  I'm assuming you understand basic administration tasks such as creating content types, and fields as well as how to create a module,.  

Although I wrote the code for Drupal 9, as I review it, I see no reason that it won't work for Drupal 8.   Since support is ending for Drupal 8, you should be upgrading to Drupal 9, but that's a different matter.

Creating The Content Type

You'll need a content type to house the links.  On this site I'm using my generic note type which I use for most of my blog posts (this allows me to add a link to any post).  But I assume you want to use a separate content type, let's create a content type named link.  In addition to the standard title and body fields you want to give it the following fields::

Label

Field Name

Field Type

Link

field_link

Link

OG Link Description

field_og_link_description

Text (plain, long)

OG Link Image Url

field_og_link_image_url

Text (plain, long)

OG Link Site Name

field_og_link_site_name

Text (plain)

OG Link Title

field_og_link_title

Text (plain)

OG Link URL

field_og_link_url

Text (plain, long

HTTP Status Code

field_http_status_code

Text (plain, long)

 

Of course you can just add these fields to an existing content type as I did, you'll just need to adjust the code as you go forward.

Building the Bookmarklet

Simply put, a bookmarklet is a browser bookmark that contains JavaScript.  We're going to create one that will open a node add form with the url of the current page already prepopulated into the link field.  This saves you the effort of copying the current URL, opening your site, navigating to the node add form and pasting in the url of the page you want to blog.  There are 2 things we need ti do to make this work:

First we'll  get Drupal to accept a parameter on the node add form's URL and prepopulate the link field.  We either need to create a new module or use a module that you already use for glue code and use hook_form_alter.


use Drupal\Core\Form\FormStateInterface;
Use Drupal\Core\Routing;

/**
 * Implements hook_form_alter().
 */
function mymodule_form_alter(&$form, FormStateInterface $form_state, $form_id) {

  // Adding custom validation for the welcome page type field.
  if ($form_id == 'node_link_form') {
  	 $form['field_link']['widget'][0]['uri']['#default_value'] = \Drupal::request()->query->get('link');
  }
}

This code basically says, "when loading the node add page for the link content type, look to see if there is a 'link' query string, and if there is, put the contents of the query string into field_link."

Next we need to get the query string into the URL .... that's where the bookmarket comes into play.  Here's a little javascript


javascript:(function(){var url=document.location.href;var uri="https://seanreiser.com/node/add/note?link="+url;window.open(uri,"_blank");}());

You need to replace "example.com" with your site's URL.  Just add a bookmark in your browser, call it something like "Add Link To My Site" and paste the javascript in as the link.  Add the bookmark to the favorites bar and when you're on a page that you want to blog about, click on the button, add any commentary in the body field and rock and roll.

There is a contributed module, Prepopulate which accomplishes the sane thing (and more) but is a little more overhead than the couple of lines of code I wrote here.  Plus, if we use contrib for the easy things, we'll never learn anything.

Fetching Metadata

Next we need to fetch the image url, site name, title and description.   You can either scrape the content for metadata server side at save or client side at when rendering the page.  I prefer doing it at save since doing it  doing it at client side will slow down page loads.  Of course, since you're caching the information, if the site changes any of the metadata, your site will be out of date.

Instead of writing code to parse out the metadata, I took advantage of opengraph.php, a library that does the heavy lifting,  Very simply, I used hook_ENTITY_TYPE_presave to populate the appropriate fields.  You can put this in the same module from above:


require_once('opengraph.php');

function mymodule_node_presave(Drupal\Core\Entity\EntityInterface $entity){
  if ($entity->bundle() == "link") {		    
    $link_url = $entity->get('field_link')->uri;	    
    if (($link_url) && !($entity->field_http_status_code->getValue())){   
      $headers = get_headers($link_url, 1);
      $og_data = OpenGraph::fetch($link_url);
      $entity->field_og_link_site_name->value = $og_data->site_name;
      $entity->field_og_link_description->value = $og_data->description;
      $entity->field_og_link_image_url->value = $og_data->image;
      $entity->field_og_link_title->value = $og_data->title;
      $entity->field_og_link_url->value = $og_data->url;
      $entity->field_http_status_code = var_export($headers,1);
    }
  }
}

This loads the open graph library, loads the page info a variable and pass the page to the library to find the  metadata and then add it to the node before it's saved.

 

 

Image
Laptop w/ Stickers

I'm Answering "What Are Your Favorite Drupal Modules?" Wrong

Lately I've been on a number of interviews and a common question seems to be, "What are your favorite modules?"  I seem to be misunderstanding the question.  

You see, I'll answer that question with a few minute soliloquy on a module like the convert bundles module and how it saved me a bunch of time  on a project where I had to merge a number of redundant content types.  Or how useful twig tweak was when I was trying to insert a block into the middle of a views listing.  Maybe discuss how I use Twig Debugger and Config Split to have twig debugging turned on in a dev region but turned off in live yet still have a common services.yml checked Into git.   You see to me "favorite" is an ephemeral thing, often tied into the problem I'm trying to solve at that moment.  And I felt an answer like this shows not just that I know a list of modules, but how I use them and helps the interviewer know my thought process.

When I answer this way, I get what feels like a combination of confusion and disappointment off the interviewer.  So I worked up the nerve on a recent interview to say, "I don't think I gave you what you want there, did I?".  She replied:

Most People Mention Views or Webform.

I immediately knew where the problem was.  I am interpreting "favorite" as "modules you have a passion for" not "modules you use on almost every build".  It's funny I have been asked "What modules do you use a lot?" and my answer to that is, "Views, Webforms, Paragraphs, Metatag, Media, starting to use Layout Builder, etc.".  But to me these are very different questions.  Much like if someone asks what my favorite beverage is, I don't mention water, although it should be the beverage I should drink most.

I also have to admit, when it comes to modules. I've become spoiled.  I start 90% of my builds with a custom install profile, based on Varbase.  I've configured a number of popular modules and don't think about them.

Either way, I live and learn.  

 

Resetting the Initial NID to 1 After Deleting All Nodes in Drupal 8

Writing this mostly as a note to myself.

I'm in the process of migrating nodes from three different Drupal sites into a new Drupal 8 site.  Over the last day or so, I've been working on building out the Migration YML (Migration API FTW).  Of course as I've been debugging I've been importing and rolling back migrations.  Of course, as I do this, my starting NID is getting larger and larger.  It was over after me test data nigrations.  When the time comes for the final import of content, I want the first NID to be 1 (just for cleanliness sake).

In Drupal 7 the delete all module did this with a "--reset" flag but doesn't in D8.  Here's the couple of lines of SQL code to reset the auto increment on the necessary tables:

 ALTER TABLE `node` AUTO_INCREMENT=1; 
ALTER TABLE `node_revision` AUTO_INCREMENT=1; 
ALTER TABLE `node_field_data` AUTO_INCREMENT=1; 
ALTER TABLE `node_field_revision` AUTO_INCREMENT=1; 

 

Make sure you DELETE all nodes before doing this, otherwise you are likely to cause data corruption down the line,

 

Share and Enjoy!

Building a Masonry Grid View with Bootstrap in Drupal 8

 

I’m in the process of moving my photography site into my current Drupal 8 site.  After a little thought I decided that I wanted to have my photos in a grid using a masonry layout (you know, the layout made famous by Pinterest).   The image attached to this article gives a quick demonstration.   On many sites this is accomplished using David DeSandro’s masonry.js.  

Drupal has a Masonry Api and a companion Masonry Views Module.  I installed the modules and they mostly worked but didn't quite meet my needs.  I wanted a little more control of the grid.  I wanted to have a responsive design with different number of columns based on screen size (1 or 2 columns on phones, 3 columns on tablets, 4 on large desktops).  Although my current design doesn't require it, I suspect  that down the road I might want certain images to span multiple columns.  I use bootstrap on my sites to make things simple so I didn't want to write custom CSS to manage column sizes.  I knew I could get the results I wanted with some work but I got to wondering, "how much work would it take to just integrate the library and write some JS".  I found the answer was under 10 lines of code and a little configuration work.

A quick google search led me to this codepen built by the developer of masonry.js which made things seem simple enough. 

So let's break down what I did:

  1. I already have a theme built which is using Bootstrap 3.  The theme's machine name and directory is seanreiser (replace seanreiser with your theme's machine name)
  2. Download the masonry library from here.
  3. Place masonry.pkgd.min.js in themes/seanreiser/libraries/masonry (so the file is located at themes/seanreiser/libraries/masonry/masonry.pkgd.min.js).
  4. Add the masonry library to the theme.  Add this code to my theme's seanreiser.libraries.yml :
    masonry: js: libraries/masonry/masonry.pkgd.min.js: {}
  5. Create a view:
    1. View Name : Photo Gallery
    2. Style : Unformatted List
    3. Settings -> Row Class -> col-xs-12 col-sm-6 col-md-4 col-lg-3
    4. Show: Fields
    5. Add the image to the field list.
  6. Attach the library to the views the view template  (themes/seanreiser/templates/view/views-view-unformatted--photo_gallery.html.twig)
    {{ attach_library('seanreiser/masonry') }}
    
    
    {% for row in rows %}
      {%	    
        set row_classes = [
          default_row_class ? 'views-row',
          loop.first ? 'views-row-first',
          loop.last ? 'views-row-last',
          'masonry'
        ]
      %}
      
      <div{{ row.attributes.addClass(row_classes) }}>
        {{- row.content -}}
      </div>
    {% endfor %}

     

  7. To my site's themes/seanreiser/js/script.js I added
    $('.views-row').masonry({ itemSelector: '.views-row', percentPosition: true });

     

  8.   Clear cache and rock and roll.

And it worked... mostly.  I had an issue where image overlapped occasionally.  The issue was that the grid would be initialized before the images were loaded.  Thankfully, David DeSandro has another library, ImagesLoaded, which detects if all your images have been loaded.  This required a couple of changes:

  1. I downloaded the library and put it in: themes/seanreiser/libraries/imagesloaded
  2. Add the imagesLoaded library to seanreiser.libraries.yml
    imagesloaded: js: libraries/imagesloaded/imagesloaded.pkgd.min.js: {}
  3. Attach the library to the view (themes/seanreiser/templates/view/views-view-unformatted--photo_gallery.html.twig):
    {{ attach_library('seanreiser/masonry') }}
    {{ attach_library('seanreiser/imagesloaded') }} 
    
    
    {% for row in rows %}
      {%	    
        set row_classes = [
          default_row_class ? 'views-row',
          loop.first ? 'views-row-first',
          loop.last ? 'views-row-last',
          'masonry'
        ]
      %}
      
      <div{{ row.attributes.addClass(row_classes) }}>
        {{- row.content -}}
      </div>
    {% endfor %}
  4. Modify your JS to hold off on initially the grid until the images are loaded (themes/seanreiser/js/script.js)
    var $grid = $('.view-photo-gallery'); $grid.imagesLoaded( function() { $grid.masonry({ itemSelector: '.views-row', percentPosition: true }); });

Now it's working just as I want.  Answers to some questions that I've been asked:

  • Why did you include the library manually instead of using the Masnory API module?
    Since I was using the library on one view in one theme, I didn't see the benefit in having the overhead of the module.  If, in the future I use masonry in other ways, I'll change this.
  • You mentioned photos spanning columns where is that?
    I haven't implemented that.  If I decide to do it I'll add a link here to the todo.
  • Where is the photography site?
    I'm in the middle of a major rebuild of a number of my sites.  Stay Tuned.
  • Update 4/24/2019 1:00AM EDT - You said less then 10 lines, there's a lot more there?
    My bad. views-view-unformatted--photo_gallery.html.twig is a copy of the default views-view-unformatted.html.twig with 2 lines added (the attach library). I am counting that as 2 lines.

Share and Enjoy!

Image
Laptop w/ Stickers

Inserting a Block Into a View Listing in Drupal 8

My blog homepage is basically an activity feed, a combination of Blog Posts, Short Notes, Images and Links, much like a facebook wall or a tumblog.  I decided I wanted to add a link to one of my other sites in the feed, basically a “house ad”.  Blatantly stealing from facebook, I decided that I wanted the second item in the feed. My goal is to create something like this:

In Drupal 7, I would have used preprocess_views_view to insert the ad into the view, but I wanted to do something that was more sustainable and not as hard coded.  Although on my blog I perform all roles (developer, designer, themes, content admin, etc), I wanted to give non-developers the ability to add, change and delete the advertisement.  So I decided to use Drupal's block interface.

I did a little investigation and found that the Twig Tweak module provides a twig function to fetch and drupal regions (and blocks, entities, fields and other Drupalisms). Here's a list of of the functions Twig Tweak provides. The drupal_region twig would do what I need.

Here’s what I did

  1. I added a region to my theme config, “view_inline_ad.”
  2. I added the block with the ad it to the region.
  3. Added the code to render the region to my views template (views-view-unformatted--blog.html.twig) {% if title %}

    {{ title }}

    {% endif %} {% for row in rows %} {% set row_classes = [ default_row_class ? 'views-row', loop.first ? 'views-row-first', loop.last ? 'views-row-last', ] %} {% if loop.index == 2 %} {{ drupal_region('view_inline_ad') }} {% endif %}
    {{ row.content }}
    {% endfor %}
  4. Cleared Cache, loaded the page and rock and roll.

One problem I ran into, on my site the blog theme is not the default theme. I host multiple sites out of the same Drupal instance and am using the Switch Page Theme module to change theme based on site context. drupal_region() assumes you're using the default theme and not the current theme so it couoldn't find the region. It does accept a second parameter, theme. I popped the theme's machine name in there and it worked like a charm.

Image
Laptop w/ Stickers

Running Multiple Sites in a Single Drupal 8 Install and Database

update 5/5/2020 This site no longer works this way.  Everything works but I made changes for branding purposes.

Introduction

So for years, in addition to what I do for clients, I've managed 5 sites for myself 4 of them public, 1 private:

Until 2 weeks ago, I've been hosting them on a server at Linode. I enjoyed using their services and things were stable for cloud hosting.  I just got to a point that I didn't want to be in the server management business anymore. I had success with Pantheon for a number of clients and have been recommending them.  I decided I'd take my own advice and move all my personal sites there. I temporally set them up as separate containers in their infrastructure.  There were 2 issues:

  1. I'd like to minimize the time I spend deploying security / maintenance updates to my sites.
  2. I wanted to minimize my costs.  None of my sites are highly trafficked so, there was really no need to have this live in live separately.

Options

Just merging the sites into a single domain with a separate domain wasn't something I was interested in doing. It may be the gemini in me, but I like that each of my interests have a different  look and feel and live under their own domain.

Using a Drupal multisite setup wasn't an option because it's not supported by Pantheon.  Additionally, I'd still be performing security and maintenance updates to 5 sites so I wouldn't save any time.

Drupal has the domain access module.   This was a contender for a bit,  The problem was that were domain access does a number of cool things it was overkill for my use case.  I didn't want to bloat my codebase with that much functionality I was never going to use.

Finally I decided I would wire it manually.

Separate Branding on Each Site

Like I said above, it was important for me to make sure that each of my sites had it's own look and feel.  To simplify things I wanted each site to have its own theme.  I'd use the path of the URL to determine which theme to use.

Drupal 7 had the themekey module which hasn't been ported to Drupal 8.  Thankfully the switch page theme module does pretty much the same thing.

 

Separating Each Site's Content

In order to separate each site I have a taxonomy term "site" to determine which site a piece of content goes to.  I have a pathauto rule setup to create aliases for each for each site:

  • /blog for the blog
  • /professional for freelance drupal
  • /photography for photography

This allows me to redirect based on url.


Redirecting Content in Settings.php

I decided to do the redirects in settings.php, so Drupal wouldn't need to bootstrap before redirecting. This code would do 2 things:
 

  • Redirect to the right homepage.  So seanreiser.photography get redirected to seanreiser.photography/photography.
  • Redirect to the right domain.  If someone goes to seanreiser.com/blog, you get redirected to bowtiesare.cool/blog.

 



if ((($request_uri[1] == "") or (substr($request_uri[1], 0, 1) == "?")) && ($_SERVER['HTTP_HOST'] == "freelancedrupal.com"))
{
    header('HTTP/1.0 301 Moved Permanently');
    header('Location: https://freelancedrupal.com/professional' . $_SERVER['REQUEST_URI']);
    exit();
}

elseif (($request_uri[1] == "professional") && ($_SERVER['HTTP_HOST'] <> "freelancedrupal.com"))
{
    header('HTTP/1.0 301 Moved Permanently');
    header('Location: https://freelancedrupal.com' . $_SERVER['REQUEST_URI']);
    exit();
}
elseif ((($request_uri[1] == "") or (substr($request_uri[1], 0, 1) == "?")) && ($_SERVER['HTTP_HOST'] == "bowtiesare.cool"))
{
    header('HTTP/1.0 301 Moved Permanently');
    header('Location: https://bowtiesare.cool/blog');
    exit();
}
elseif (($request_uri[1] == "blog") && ($_SERVER['HTTP_HOST'] <> "bowtiesare.cool"))
{
    header('HTTP/1.0 301 Moved Permanently');
    header('Location: https://bowtiesare.cool' . $_SERVER['REQUEST_URI']);
    exit();
}
elseif ((($request_uri[1] == "") or (substr($request_uri[1], 0, 1) == "?")) && ($_SERVER['HTTP_HOST'] == "seanreiser.photography"))
{
    header('HTTP/1.0 301 Moved Permanently');
    header('Location: https://seanreiser.photography/photography');
    exit();
}
elseif (($request_uri[1] == "photography") && ($_SERVER['HTTP_HOST'] <> "seanreiser.photography"))
{
    header('HTTP/1.0 301 Moved Permanently');
    header('Location: https://seanreiser.photography' . $_SERVER['REQUEST_URI']);
    exit();
}
elseif (($request_uri[1] == "sean-reiser") && ($_SERVER['HTTP_HOST'] <> "seanreiser.com"))
{
    header('HTTP/1.0 301 Moved Permanently');
    header('Location: https://seanreiser.com' . $_SERVER['REQUEST_URI']);
    exit();
}
elseif (($request_uri[1] == "resume") && ($_SERVER['HTTP_HOST'] <> "seanreiser.com"))
{
    header('HTTP/1.0 301 Moved Permanently');
    header('Location: https://seanreiser.com' . $_SERVER['REQUEST_URI']);
    exit();
}

Algorithmic URL Shortening for Drupal 7, 8 and 9

I originally wrote this for Drupal 7, if you are you using Drupal 8 or 9 there are updates here

Recently I've been thinking about how to make URL shortening a little simpler for Drupal sites (well, my blog but this could apply to any site). You see, in addition to seanreiser.com, I own sr7.us which I bought to use as an URL shortener. I took a look at a few of the existing modules and they all seem to work in similar ways, either they take advantage of Drupal's Path module and auto generate a slug that can be used in the short url and the forward happens automagically or they maintain a separate table and do the forward in a hook_init call or via the Redirect Module (which again, works in hook_init). These methods are sound Drupal best practice but when I look at them I see two potential problems:

  1. There are still the potential for URL collisions since a path could defined in both hook_menu and as a URL alias.
  2. You have to wait for Drupal to fully bootstrap in order to decode a short URL and do a redirect.

These aren't horrible constraints but it got me to thinking, "Is there a way to do the shortening algorithmically, so it wouldn't require Drupal to bootstrap and yet be reasonably assured that there would be no collisions".

So I took a look at all the paths in the URL alias table and the menu router table for all the systems I am responsible for and I noticed that every path either have more then one argument (not empty arg(1)) or arg(0) contains a vowel. The only cases where arg(0) didn't contain a vowel was when it was numeric and generally had a format like 2013/01/16/my-article-title-here. When you consider the bias toward English in module development (as well as the sites I work on) this makes some sense. Of course this won't work with Welsh since they have words like crwth, cwtch or cwm and when Chinese is converted to puny code but for the 70% of the internet that uses English and other European languages this is a useful hack.

The only pages I'm looking to create a short URL for are nodes (individual pieces of content). So, I can use the Node ID as a key to encode and decode the URL.

All that said if we consider that URLs are case sensitive we can use a dataset of "0123456789bcdfghjklmnpqrstvwxzBCDFGHJKLMNPQRSTVWXZ" (note the lack of a,e,i,o,u,y both lower and upper case) we wind up with a bastardized base 50. At the core of this are 2 generic functins which convert a decimal to any base, and any base to a decimal:


function shorturl_dec2any($num, $base = 62, $index = false)
{
    if (!$base)
    {
        $base = strlen($index);
    }
    else if (!$index)
    {
        $index = substr("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", 0, $base);
    }
    $out = "";
    for ($t = floor(log10($num) / log10($base));$t-- >= 0;$t--)
    {
        $a = floor($num / pow($base, $t));
        $out = $out . substr($index, $a, 1);
        $num = $num - ($a * pow($base, $t));
    }
    return $out;
}
function shorturl_any2dec($num, $base = 62, $index = false)
{
    if (!$base)
    {
        $base = strlen($index);
    }
    else if (!$index)
    {
        $index = substr("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", 0, $base);
    }
    $out = 0;
    $len = strlen($num) - 1;
    for ($t = 0;$t <= $len;$t++)
    {
        $out = $out + strpos($index, substr($num, $t, 1)) * pow($base, $len - $t);
    }
    return $out;
}


    

 

I copypasta'ed these a while ago from someplace on http://php.net, but the link seems to be dead now. Either way, they were posted for educational purposes, and I'm passing that forward. I just wish I could credit the original author.

 

You'll notice that the default for these functions is Base 62, containing the entire alpha-numeric universe of characters. To limit this I have 2 wrapper functions:

 



DEFINE(BASE50CHARS,"0123456789bcdfghjklmnpqrstvwxzBCDFGHJKLMNPQRSTVWXZ");
 
function shorturl_encode($nid){
     return shorturl_dec2any($nid, 50, BASE50CHARS);
 
}
 
function shorturl_decode($slug){
 
     if (shorturl_validate_slug($slug)){
          return shorturl_any2dec($slug, 50, BASE50CHARS);
     }
     else{
          return false;
     }
}

 

So I'm sure you've noticed that there's a validate slug function. Basically it's there to ensure that the Slug is valid (contains no vowels) Here's what I'm doing:

 


function shorturl_validate_slug($slug) {
 
  // check the length of the string
  if (strlen($slug) == 0) {
    return FALSE;
  }
 
  // check for vowels  
  return preg_match('/[aeiouyAEIOUY]/i', $slug) ? FALSE : TRUE;
}

Next up the function actually doing the forwarding in a hook_boot call



function shorturl_boot() {
  if ($_GET['q'] && (shorturl_validate_slug($_GET['q'])) && ($nid = shorturl_decode_index($_GET['q']))) {
    drupal_goto('node/' . $nid, array() , 302);
  }
}

Now it's not perfect (what in the world is). You can see it in action across the site here. For example this aricle can be found at http://sr7.us/2gM as well as the conical URL of https://seanreiser.com/blog/note/algorithmic-url-shortening-drupal. I need to clean a couple of things up, but it's my intention to post this as a sandbox project shortly.

Share and Enjoy!

7/21/2021 - Updates for Drupal 8 and 9

Since both hook_boot and drupal_goto were deprecated in Drupal 8, I needed to do some rejiggering to get this to work. I could’ve setup an event subscriber, but again the goal is to have the code execute before Drupal fully bootstraps. It’s become more popular to insert code into settings.php so I went that route.

The code is no longer stored in a module. The URL encoding / decoding is stored in /sites/default/shorturl.php (I refactored the code from above).



function shorturl_encode($num)
{
    $index = "0123456789bcdfghjklmnpqrstvwxzBCDFGHJKLMNPQRSTVWXZ";
    $out = "";
    for ($t = floor(log10($num) / log10(50));$t-- >= 0;$t--)
    {
        $a = floor($num / pow(50, $t));
        $out = $out . substr($index, $a, 1);
        $num = $num - ($a * pow(50, $t));
    }
    return $out;
}
function shorturl_decode($num)
{
    $index = "0123456789bcdfghjklmnpqrstvwxzBCDFGHJKLMNPQRSTVWXZ";
    $out = 0;
    $len = strlen($num) - 1;
    for ($t = 0;$t <= $len;$t++)
    {
        $out = $out + strpos($index, substr($num, $t, 1)) * pow(50, $len - $t);
    }
    return $out;
}

I then added the following to the top of settings.php


include_once 'shorturl.php';

$request_uri = explode('/', $_SERVER['REQUEST_URI']);

if (($request_uri[1] <> "") && !(preg_match('/[aeiouyAEIOUY.]/i', $request_uri[1])))
{
    $nid = shorturl_decode($request_uri[1]);
    header('HTTP/1.0 301 Moved Permanently');
    header('Location: https://example.com/node/' . $nid);
    exit();
}

All in all, it works well, ia compatible with my D7 solution and works with D8 and D9.

7/23/2021 - Fixing the Facebook Query String Bug

When I published the D8 D9 version of this post, at first things went swimmingly. I used the short URL when I linked to it it on LonkedIn, Twitter and Facebook. Things were fine until I published it into a Facebook Group, when people said they were getting a 404. A few minutes of debugging and I found that the links from the links from the facebook group contained a query string, so I made a quick adjustment in settings.php.


 
 $request_uri = explode('/', $_SERVER['REQUEST_URI']);
 
 if ($pos = strpos($request_uri[1], "?"))
 {
     $request_uri[1] = substr($request_uri[1], 0, $pos);
 }
 if (($request_uri[1] <> "") && !(preg_match('/[aeiouyAEIOUY.]/i', $request_uri[1])))
 {
     $nid = shorturl_decode($request_uri[1]);
     header('HTTP/1.0 301 Moved Permanently');
     header('Location: https://seanreiser.com/node/' . $nid);
     exit();
 }

Basicily, I test for the existace of a "?" and lop off anything that follows it

Image
Laptop w/ Stickers