Skip to content

Commit 6031e87

Browse files
committed
Add embedded perl module for nginx subdomain redirects
* Add ReadTheDocs.pm for reading metadata file as json * Add tests for RTD.pm * Update nginx salt config * Uses project language settings for nginx redirect
1 parent 0cd46c5 commit 6031e87

File tree

6 files changed

+314
-8
lines changed

6 files changed

+314
-8
lines changed

deploy/salt/nginx/init.sls

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
# Nginx
22

33
nginx:
4-
pkg:
5-
- installed
4+
pkg.installed:
5+
- name: nginx-extras
66
service.running:
77
- enable: True
88
- require:
99
- pkg: nginx
10-
- watch:
11-
- file: /etc/nginx/sites-enabled/readthedocs
1210

1311
/etc/nginx/nginx.conf:
1412
file.managed:
@@ -18,3 +16,8 @@ nginx:
1816
/etc/nginx/sites-enabled/default:
1917
file:
2018
- absent
19+
20+
/usr/share/nginx/perl:
21+
file.directory:
22+
- require:
23+
- pkg: nginx
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package ReadTheDocs;
2+
3+
use strict;
4+
use warnings;
5+
6+
use JSON qw//;
7+
use I18N::AcceptLanguage;
8+
9+
10+
=head2 redirect_home
11+
12+
Redirect project subdomain home to correct build path, bypassing Django. This
13+
requires the project have a C<metadata.json> that is updated when a project is
14+
built or settings are saved. The metadata can include the following keys:
15+
16+
=over
17+
18+
=item version
19+
20+
Default project version, will default to C<latest>
21+
22+
=item language
23+
24+
Default project language, will default to C<en>
25+
26+
=item languages
27+
28+
Languages from linked translation projects. The default language will be added
29+
to this list as the primary language. A lookup on the C<Accept-Language> header
30+
will redirect to the correct language page, as long as there is a translation
31+
for the project.
32+
33+
=back
34+
35+
Inside the Nginx config, the variable C<rtd_metadata> should be set to the path
36+
of the C<metadata.json> path:
37+
38+
location ~ /$ {
39+
set $rtd_metadata /home/docs/sites/readthedocs.org/checkouts/readthedocs.org/user_builds/$domain/metadata.json;
40+
perl ReadTheDocs::redirect_home;
41+
}
42+
43+
44+
=cut
45+
46+
sub redirect_home {
47+
my $req = shift;
48+
my $version = 'latest';
49+
my $lang = 'en';
50+
51+
my $metadata_file = $req->variable('rtd_metadata');
52+
if (defined $metadata_file) {
53+
my $metadata = project_metadata($metadata_file);
54+
55+
# Version
56+
$version = $metadata->{version} || 'latest';
57+
58+
# Language, add default as the primary language
59+
$lang = $metadata->{language} || 'en';
60+
my $languages = $metadata->{languages};
61+
unshift(@{$languages}, $lang);
62+
63+
my $header = $req->header_in('Accept-Language');
64+
my $accept = I18N::AcceptLanguage->new();
65+
$accept->defaultLanguage($lang);
66+
$lang = $accept->accepts($header, $languages);
67+
}
68+
69+
# Return redirect, no body
70+
$req->header_out('Location', sprintf('/%s/%s/', $lang, $version));
71+
return 302;
72+
}
73+
74+
=head2 project_metadata($project)
75+
76+
Return parsed metadata from JSON metadata file
77+
78+
=cut
79+
80+
sub project_metadata {
81+
my $filename = shift;
82+
my $file = project_metadata_read($filename);
83+
my $metadata = {};
84+
eval {
85+
$metadata = JSON->new->utf8->decode($file);
86+
};
87+
return $metadata;
88+
}
89+
90+
sub project_metadata_read {
91+
my $filename = shift;
92+
my $file = "";
93+
if (-e $filename) {
94+
open(my $fh, '<', $filename);
95+
$file = <$fh>;
96+
}
97+
return $file;
98+
}
99+
100+
1;
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
use Test::More;
2+
use ReadTheDocs;
3+
4+
plan tests => 46;
5+
6+
no strict 'refs';
7+
no warnings 'redefine';
8+
9+
10+
our $Metadata_Fixture = {
11+
'/tmp/foobar' => {
12+
name => 'foobar',
13+
version => '1.1',
14+
languages => ['es', 'en', 'fr']
15+
},
16+
'/tmp/test' => {
17+
name => 'test',
18+
version => '2.2',
19+
language => 'es',
20+
languages => ['fr', 'ru']
21+
},
22+
'/tmp/lang' => {
23+
name => 'lang',
24+
version => '3.3',
25+
language => 'ru',
26+
languages => ['en']
27+
}
28+
};
29+
30+
{
31+
package ReadTheDocs::Request;
32+
33+
sub new {
34+
my $class = shift;
35+
my %args = @_;
36+
bless {
37+
%args,
38+
output => []
39+
}, $class;
40+
}
41+
42+
sub variable {
43+
my $self = shift;
44+
my $name = shift;
45+
return $self->{variable}->{$name};
46+
}
47+
48+
sub header_in {
49+
my $self = shift;
50+
my $name = shift;
51+
return $self->{header}->{$name};
52+
}
53+
54+
sub header_out {
55+
my ($self, $name, $value) = @_;
56+
push(@{$self->{output}}, sprintf("%s: %s", $name, $value));
57+
}
58+
59+
sub send_http_header {
60+
my $self = shift;
61+
return;
62+
}
63+
64+
sub print {
65+
my $self = shift;
66+
push(@{$self->{output}}, shift);
67+
}
68+
}
69+
70+
{
71+
local *ReadTheDocs::project_metadata = sub {
72+
my $file = shift;
73+
return $Metadata_Fixture->{$file};
74+
};
75+
76+
# Test metadata
77+
my $metadata = ReadTheDocs::project_metadata('/tmp/foobar');
78+
is($metadata->{name}, 'foobar');
79+
is($metadata->{version}, '1.1');
80+
81+
# Mock redirection for project 'foobar'
82+
my $test_cb = sub {
83+
my ($lang, $metadata, $url) = @_;
84+
my $req = ReadTheDocs::Request->new(
85+
variable => {rtd_metadata => $metadata},
86+
header => {'Accept-Language' => $lang}
87+
);
88+
is(ReadTheDocs::redirect_home($req), 302);
89+
is(
90+
pop(@{$req->{output}}),
91+
sprintf('Location: %s', $url),
92+
sprintf('Testing %s -> %s', $metadata, $url)
93+
);
94+
};
95+
96+
# Languages: ['es', 'en', 'fr']
97+
$test_cb->('es,en', '/tmp/foobar', '/es/1.1/');
98+
$test_cb->('du', '/tmp/foobar', '/en/1.1/');
99+
$test_cb->('en;q=0.8, es', '/tmp/foobar', '/es/1.1/');
100+
$test_cb->('ru', '/tmp/foobar', '/en/1.1/');
101+
$test_cb->('en-gb;q=0.8, en;q=0.7, da', '/tmp/foobar', '/en/1.1/');
102+
103+
# Languages: ['es', 'fr', 'ru']
104+
$test_cb->('es,en', '/tmp/test', '/es/2.2/');
105+
$test_cb->('du', '/tmp/test', '/es/2.2/');
106+
$test_cb->('en;q=0.8, es', '/tmp/test', '/es/2.2/');
107+
$test_cb->('ru', '/tmp/test', '/ru/2.2/');
108+
$test_cb->('en-gb;q=0.8, en;q=0.7, da', '/tmp/test', '/es/2.2/');
109+
110+
# Languages: ['ru', 'en']
111+
$test_cb->('es,en', '/tmp/lang', '/en/3.3/');
112+
$test_cb->('du', '/tmp/lang', '/ru/3.3/');
113+
$test_cb->('en;q=0.8, es', '/tmp/lang', '/en/3.3/');
114+
$test_cb->('ru', '/tmp/lang', '/ru/3.3/');
115+
$test_cb->('ru', '/tmp/lang', '/ru/3.3/');
116+
$test_cb->('en-gb;q=0.8, en;q=0.7, da', '/tmp/lang', '/en/3.3/');
117+
118+
# Languages: ['en']
119+
$test_cb->('es,en', '/tmp/nonexistant', '/en/latest/');
120+
$test_cb->('du', '/tmp/nonexistant', '/en/latest/');
121+
$test_cb->('en;q=0.8, es', '/tmp/nonexistant', '/en/latest/');
122+
$test_cb->('ru', '/tmp/nonexistant', '/en/latest/');
123+
$test_cb->('ru', '/tmp/nonexistant', '/en/latest/');
124+
$test_cb->('en-gb;q=0.8, en;q=0.7, da', '/tmp/nonexistant', '/en/latest/');
125+
}
126+
127+
1;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
use Test::More;
2+
use ReadTheDocs;
3+
4+
plan tests => 6;
5+
6+
no strict 'refs';
7+
no warnings 'redefine';
8+
9+
10+
our $Metadata_JSON = {};
11+
$Metadata_JSON->{'/tmp/foo'} = qq/
12+
{"name": "foo-name", "version": "foo-version"}
13+
/;
14+
$Metadata_JSON->{'/tmp/bar'} = qq/
15+
{"name": "bar-name", "nothing": "something"}
16+
/;
17+
$Metadata_JSON->{'/tmp/fail'} = qq/
18+
{"name": "fail", non of this matters}
19+
/;
20+
21+
{
22+
local *ReadTheDocs::project_metadata_read = sub {
23+
my $file = shift;
24+
return $Metadata_JSON->{$file};
25+
};
26+
27+
my $metadata = ReadTheDocs::project_metadata('/tmp/foo');
28+
is($metadata->{name}, 'foo-name');
29+
is($metadata->{version}, 'foo-version');
30+
undef $metadata;
31+
32+
my $metadata = ReadTheDocs::project_metadata('/tmp/bar');
33+
is($metadata->{name}, 'bar-name');
34+
is($metadata->{version}, undef);
35+
undef $metadata;
36+
37+
my $metadata = ReadTheDocs::project_metadata('/tmp/test');
38+
is($metadata->{name}, undef);
39+
is($metadata->{version}, undef);
40+
undef $metadata;
41+
}
42+
43+
1;

deploy/salt/nginx/sites/readthedocs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
perl_modules perl;
2+
perl_require ReadTheDocs.pm;
3+
14
server {
25
listen 8000;
36
server_name media.readthedocs.org;
@@ -88,16 +91,27 @@ server {
8891
break;
8992
}
9093

91-
location ~* /en/(.+)/(.*) {
92-
alias /home/docs/sites/readthedocs.org/checkouts/readthedocs.org/user_builds/$domain/rtd-builds/$1/$2;
94+
location ~* /en/(?<version>.+)(?:/(.*)|$) {
95+
alias /home/docs/sites/readthedocs.org/checkouts/readthedocs.org/user_builds/$domain/rtd-builds/$version/$1;
9396
error_page 404 = @fallback;
9497
error_page 500 = @fallback;
9598
add_header X-Served Nginx;
9699
add_header X-Deity {{ grains['host'] }};
97100
}
98-
location / {
99-
rewrite (.*) http://$domain.readthedocs.org/en/latest/;
101+
102+
location ~* /(?<lang>\w\w)/(?<version>.+)(?:/(.*)|$) {
103+
alias /home/docs/sites/readthedocs.org/checkouts/readthedocs.org/user_builds/$domain/translations/$lang/$version/$1;
104+
error_page 404 = @fallback;
105+
error_page 500 = @fallback;
106+
add_header X-Served Nginx;
107+
add_header X-Deity {{ grains['host'] }};
100108
}
109+
110+
location ~ /$ {
111+
set $rtd_metadata /home/docs/sites/readthedocs.org/checkouts/readthedocs.org/user_builds/$domain/metadata.json;
112+
perl ReadTheDocs::redirect_home;
113+
}
114+
101115
location @fallback {
102116
proxy_pass http://127.0.0.1:8888;
103117
proxy_buffering off;

deploy/salt/readthedocs/site.sls

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,23 @@ readthedocs-{{ service }}:
153153
- service: nginx
154154
- require:
155155
- file: /etc/nginx/nginx.conf
156+
- file: /usr/share/nginx/perl/ReadTheDocs.pm
156157
- service: readthedocs-gunicorn
158+
159+
/usr/share/nginx/perl/ReadTheDocs.pm:
160+
file.managed:
161+
- source: salt://nginx/perl/lib/ReadTheDocs.pm
162+
- watch_in:
163+
- service: nginx
164+
- require:
165+
- file: /usr/share/nginx/perl
166+
- pkg: libi18n-acceptlanguage-perl
167+
- pkg: libjson-perl
168+
169+
libi18n-acceptlanguage-perl:
170+
pkg:
171+
- installed
172+
173+
libjson-perl:
174+
pkg:
175+
- installed

0 commit comments

Comments
 (0)