Ok, I've worked it out. I'll type out the cause and solution here for reference in case anyone else ever runs into it.
With some debugging I traced the issue back to lines 141-149 of sapphire/core/Core.php (this is in SilverStripe version 2.4.5) on the PHP side of things.
These lines contain the following code:
// Determine the base URL by comparing SCRIPT_NAME to SCRIPT_FILENAME and getting the common
// elements
if(substr($_SERVER['SCRIPT_FILENAME'],0,strlen(BASE_PATH)) == BASE_PATH) {
$urlSegmentToRemove = substr($_SERVER['SCRIPT_FILENAME'],strlen(BASE_PATH));
if(substr($_SERVER['SCRIPT_NAME'],-strlen($urlSegmentToRemove)) == $urlSegmentToRemove) {
$baseURL = substr($_SERVER['SCRIPT_NAME'], 0, -strlen($urlSegmentToRemove));
define('BASE_URL', rtrim($baseURL, DIRECTORY_SEPARATOR));
}
}
Turns out SCRIPT_FILENAME and SCRIPT_NAME had the following values:
- SCRIPT_FILENAME - /var/www/vhosts/mydomain.com/httpdocs/sapphire/main.php
- SCRIPT_NAME - /admin/cms/versions/1
My first thought was hurray I found it, but it took but a second to realize that wasn't quite the case.
You should in fact be able to establish what the base url is with knowledge of SCRIPT_FILENAME and SCRIPT_NAME.
Nothing wrong with that - what's wrong is the data. More specifically the value of SCRIPT_NAME in this case.
Taken from RFC3875 - The Common Gateway Interface (CGI) Version 1.1 - section 3.3:
The server
1. MAY preserve the URI in the particular client request; or
2. it MAY select a canonical URI from the set of possible values
for each script; or
3. it can implement any other selection of URI from the set.
From the meta-variables thus generated, a URI, the 'Script-URI', can
be constructed. This MUST have the property that if the client had
accessed this URI instead, then the script would have been executed
with the same values for the SCRIPT_NAME, PATH_INFO and QUERY_STRING
meta-variables.
Thus it would be a good idea to make sure my webserver starts filling in the right value for SCRIPT_NAME to avoid any more issues like this one.
As such the next question is raised: why doesn't SCRIPT_NAME have the right value?
The answer to this is simple. How it originated however is less clear on the surface, so let's look at it step by step.
Nginx uses an event based architecture. Put simply: it's asynchonous, whereas Apache is not and nor is PHP.
The fact PHP isn't asynchronous whilst Nginx is means PHP can't be embedded the way it is as an Apache module on the vast majority of webservers out there.
Therefore PHP needs to run separately and Nginx needs to communicate to it by some means. This is usually done using FastCGI.
Why does this all matter? Well, it means we as network administrators or software developers or whatever we all are need to pay a little more attention to the communication between the webserver and PHP when we're using Nginx. We need to make sure all the parameters are right for PHP to work correctly, because Nginx sure as hell isn't gonna do it for us.
Let's make it a little less abstract. This is what we need to make happen:
<IfModule mod_rewrite.c>
SetEnv HTTP_MOD_REWRITE On
RewriteEngine On
RewriteCond %{REQUEST_URI} ^(.*)$
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule .* sapphire/main.php?url=%1&%{QUERY_STRING} [L]
</IfModule>
What you see here is the Apache rewrite rule taken from the [SilverStripe root]/.htaccess file.
The SilverStripe documentation has a suggestion on how to get this done in the Nginx configuration:
if (!-f $request_filename) {
rewrite ^/(.*?)(\?|$)(.*)$ /sapphire/main.php?url=$1&$3 last;
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME /home/yoursite.com/httpdocs$fastcgi_script_name;
include fastcgi_params;
}
Yeah.... I don't know when that page was last edited but I hope it was a long time ago, because in the current version of Nginx that is very, very inefficient.
It does work, but the most reasonable thing that gets close which does essentially the same using the same directives and a lot less CPU cycles is the following:
nginx.conf
if (!-f $request_filename) {
rewrite ^ /sapphire/main.php?url=$uri&$args last;
}
location ~ \.php$ {
include fastcgi.conf;
fastcgi_pass 127.0.0.1:9000;
}
fastcgi.conf
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param QUERY_STRING $query_string;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param DOCUMENT_URI $document_uri;
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param SERVER_PROTOCOL $server_protocol;
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
fastcgi_param REMOTE_ADDR $remote_addr;
fastcgi_param REMOTE_PORT $remote_port;
fastcgi_param SERVER_ADDR $server_addr;
fastcgi_param SERVER_PORT $server_port;
fastcgi_param SERVER_NAME $server_name;
# PHP only, required if PHP was built with --enable-force-cgi-redirect
fastcgi_param REDIRECT_STATUS 200;
Still this is stone age stuff though, given the fact we're using an if-statement.
When you're using Nginx you don't generally do that 'for comfort'. Most people won't settle just for the 'base gain' of migrating to Nginx in the first place.
Once you've gotten to the point you want to arm your servers with the weapon of mass page delivery that is Nginx you tend to take a moment for all those minor performance tweaks that keep adding up in your benefit as well.
We move on...
On December the 15th, 2008, Nginx 0.7.27 was released and introduced a new directive: try_files. That day an unmeasurable amount of documentation spread across the internet explaining just how to do this very thing became trash.
Due to the specific implementation of try_files in Nginx it was and is a much more efficient alternative to do what many if-statements were used for to do before.
As such the next logical step became the following:
location / {
try_files $uri @sapphire_delegate;
}
location @sapphire_delegate {
rewrite ^ /sapphire/main.php?url=$uri&$args last;
}
location ~ \.php$ {
include fastcgi.conf;
fastcgi_pass 127.0.0.1:9000;
}
So far all given methods do work.
But then there's one more substantial optimization we can make here. The page on Nginx in the SilverStripe documentation also links to a page on the Nginx wiki containing an example of how to configure for SilverStripe.
This optimization has been applied in that example, but faulty - basically the way I did it first earlier.
The rewrite can be ditched. Why should we perform a whole other internal rewrite that isn't necessary and then match the new url to another location block which isn't necessary either if we can avoid both? Let's not.
And this is where the error sneaks in....
location / {
try_files $uri @sapphire_delegate;
}
location @sapphire_delegate {
include fastcgi.conf;
fastcgi_param SCRIPT_FILENAME $document_root/sapphire/main.php;
fastcgi_param QUERY_STRING url=$uri&$args;
fastcgi_pass 127.0.0.1:9000;
}
What makes this a lot more efficient than the previous example is the fact that as soon as we've passed the request to what I'll call the SilverStripe / Sapphire handler location block for not pointing to an existing file we're stuffing it in the pipeline destined for PHP.
In this case there's no time being wasted anymore internally rewriting the url and / or matching to another location block to detect it needs to be handled as PHP.
However, because we are not rewriting the url neither Nginx nor PHP has a clue what in the world kind of file we were thinking about executing in the first place. As a result the explicit definition of SCRIPT_FILENAME you see here was put in to fill the gap the rewrite left... and nothing else...
SCRIPT_FILENAME in this case was merely meant to tell PHP what script to execute and it does that - it does it just fine.
Though now the issue remains SCRIPT_NAME is still out of sync and therefore that should be definined explicitly as well.
Thus today the day in 2011 the au courant method of configuring Nginx for serving SilverStripe CMS is the following:
nginx.conf
location / {
try_files $uri @sapphire_delegate;
}
location @sapphire_delegate {
include fastcgi.conf;
fastcgi_param SCRIPT_FILENAME $document_root/sapphire/main.php;
fastcgi_param SCRIPT_NAME /sapphire/main.php;
fastcgi_param QUERY_STRING url=$uri&$args;
fastcgi_pass 127.0.0.1:9000;
}
fastcgi.conf
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param QUERY_STRING $query_string;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param DOCUMENT_URI $document_uri;
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param SERVER_PROTOCOL $server_protocol;
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
fastcgi_param REMOTE_ADDR $remote_addr;
fastcgi_param REMOTE_PORT $remote_port;
fastcgi_param SERVER_ADDR $server_addr;
fastcgi_param SERVER_PORT $server_port;
fastcgi_param SERVER_NAME $server_name;
# PHP only, required if PHP was built with --enable-force-cgi-redirect
fastcgi_param REDIRECT_STATUS 200;
# Fix for "upstream sent too big header while reading response header from upstream" errors
# These errors were encountered here on standard operation of SilverStripe CMS
fastcgi_connect_timeout 60;
fastcgi_send_timeout 180;
fastcgi_read_timeout 180;
fastcgi_buffer_size 128k;
fastcgi_buffers 4 256k;
fastcgi_busy_buffers_size 256k;
fastcgi_temp_file_write_size 256k;
fastcgi_intercept_errors on;