Commit 39b444da authored by Luc Didry's avatar Luc Didry

Really big commit

* Change internationalization system (.po files now)
* Fixed domain for shortened URLs system (#12)
* Fix XSS vulnerability (#15)
* Subdirectory mounting system (#16)
* Show your URLs page
* Show all URLs page for admin (#11)
* API description page
* API change
* Self-documented configuration template
parent 2102b93a
EXTRACTFILES=utilities/locales_files.txt
EXTRACTTO=lib/Lstu/I18N/en.po
XGETTEXT=carton exec local/bin/xgettext.pl
locales:
$(XGETTEXT) -f $(EXTRACTFILES) -o $(EXTRACTTO)
# LSTU
# Lstu
## What Lstu means ?
Please register or sign in to reply
## What LSTU means ?
It means Let's Shorten That Url.
## License
Lstu is licensed under the terms of the WTFPL. See the LICENSE file.
## Dependancies
Please register or sign in to reply
* Carton : Perl dependancies manager, it will get what you need, so don't bother for dependancies (but you can read the file `cpanfile` if you want).
Please register or sign in to reply
* Carton : Perl dependencies manager, it will get what you need, so don't bother for dependencies (but you can read the file `cpanfile` if you want).
```shell
sudo cpan Carton
......@@ -15,6 +18,7 @@ sudo cpan Carton
## Installation
After installing Carton :
```shell
git clone https://git.framasoft.org/luc/lstu.git
cd lstu
......@@ -23,6 +27,7 @@ cp lstu.conf.template lstu.conf
```
## Usage
```
carton exec hypnotoad script/lstu
```
......@@ -31,14 +36,21 @@ Yup, that's all, it will listen at "http://127.0.0.1:8080".
For more options (interfaces, user, etc.), change the configuration in `lstu.conf` (have a look at http://mojolicio.us/perldoc/Mojo/Server/Hypnotoad#SETTINGS for the available options).
## Other options
There is the `contact` option (mandatory), where you have to put some way for the users to contact you, and the `secret` where you have to put a random string in order to protect your Mojolicious cookies (not really useful and optional).
Please, have a look at the `lstu.conf.template`, it's full of options and self-documented.
## How many urls can it handle ?
Well, by default, there is 8 361 453 672 available combinations. I think the sqlite db will explod before you reach this limit. If you want more shortened URLs than that, open `lstu.conf` and change the `length` setting.
Everytime somebody uses LSTU, it will create 'waiting' shortened URLs codes in order to be quick to shorten the URLs.
By default, there are 8 361 453 672 available combinations. I think the sqlite db will explode before you reach this limit. If you want more shortened URLs than that, open `lstu.conf` and change the `length` setting.
Accordingly to the `lstu.conf` configuration file, it will create `provisionning` waiting URLs, adding them `provis_step` by `provis_step`.
Everytime somebody uses Lstu, it will create 'waiting' shortened URLs codes in order to be quick to shorten the URLs.
This provisionning asks to modify your database if your updating LSTU from 0.01 to 0.02:
Accordingly to the `lstu.conf` configuration file, it will create `provisioning` waiting URLs, adding them `provis_step` by `provis_step`.
This provisioning asks to modify your database if you're updating Lstu from 0.01 to 0.02:
```shell
sqlite3 lstu.db
```
......@@ -49,35 +61,28 @@ UPDATE SQLITE_MASTER SET SQL = 'CREATE TABLE lstu (short TEXT PRIMARY KEY, url T
PRAGMA writable_schema = 0;
```
## Other options
Well, there is the `contact` option, where you a to put some way for the users to contact you, and the `secret` where you have to put a random string in order to protect your Mojolicious cookies (not really useful and optional).
## Reverse proxy
You can use a reverse proxy like Nginx or Varnish (or Apache with the mod\_proxy module). The web is full of tutos.
## Internationalization
LSTU comes with english and french languages. It will choose the language to display with the browser's settings.
If you want to add more languages, for example german:
```shell
cd lib/I18N
cp en.pm de.pm
vim de.pm
```
Lstu comes with English and French languages. It will choose the language to display with the browser's settings.
If you want to add more languages, please help on <https://www.transifex.com/projects/p/lstu/>
There's just a few sentences, so it will be quick to translate. Please consider to send me you language file in order to help the other users :smile:.
There are just a few sentences, so it will be quick to translate.
If you add sentences to translate, just do `make locales` to update the en.po file and don't forget to update the others translation files.
## Official instance
You can see it working and use it at http://lstu.fr.
You can see it working and use it at https://lstu.fr.
## API
You can shorten an URL with a GET request:
`http://lstu.fr/a?lsturl=http://example.com&format=json`
With `format=json`, you will get a json string like that:
`{"short":"http:\/\/lstu.fr\/XuHRAT6P","success":true,"url":"http:\/\/example.com"}`
See https://lstu.fr/api
If you don't use `format=json`, you will be redirected to http://lstu.fr where the shortened URL informations will be displayed.
## Others projects dependencies
Please register or sign in to reply
## Others projects dependancies
Lstu is written in Perl with the Mojolicious framework and uses the Twitter bootstrap framework to look not too ugly.
......@@ -2,3 +2,5 @@ requires 'Mojolicious', '>= 4.63';
requires 'ORLite';
requires 'Data::Validate::URI';
requires 'Mojolicious::Plugin::I18N';
requires 'Locale::Maketext';
requires 'Locale::Maketext::Extract';
# carton snapshot format: version 1.0
DISTRIBUTIONS
CPAN-Meta-2.150001
pathname: D/DA/DAGOLDEN/CPAN-Meta-2.150001.tar.gz
provides:
CPAN::Meta 2.150001
CPAN::Meta::Converter 2.150001
CPAN::Meta::Feature 2.150001
CPAN::Meta::History 2.150001
CPAN::Meta::Merge 2.150001
CPAN::Meta::Prereqs 2.150001
CPAN::Meta::Spec 2.150001
CPAN::Meta::Validator 2.150001
requirements:
CPAN::Meta::Requirements 2.121
CPAN::Meta::YAML 0.008
Carp 0
ExtUtils::MakeMaker 6.17
JSON::PP 2.27200
Parse::CPAN::Meta 1.4414
Scalar::Util 0
perl 5.008
strict 0
version 0.88
warnings 0
DBD-SQLite-1.40
pathname: I/IS/ISHIGAKI/DBD-SQLite-1.40.tar.gz
provides:
......@@ -9,7 +32,7 @@ DISTRIBUTIONS
DBD::SQLite::dr 1.40
requirements:
DBI 1.57
ExtUtils::MakeMaker 6.48
ExtUtils::MakeMaker 0
File::Spec 0.82
Test::Builder 0.86
Test::More 0.47
......@@ -156,8 +179,86 @@ DISTRIBUTIONS
ExtUtils::MakeMaker 0
Test::More 0.31
Time::HiRes 0
Mojolicious-4.39
pathname: S/SR/SRI/Mojolicious-4.39.tar.gz
Locale-Maketext-Lexicon-1.00
pathname: D/DR/DRTECH/Locale-Maketext-Lexicon-1.00.tar.gz
provides:
Locale::Maketext::Extract 1.00
Locale::Maketext::Extract::Plugin::Base 1.00
Locale::Maketext::Extract::Plugin::FormFu 1.00
Locale::Maketext::Extract::Plugin::FormFu::Extractor 1.00
Locale::Maketext::Extract::Plugin::Generic 1.00
Locale::Maketext::Extract::Plugin::Haml 1.00
Locale::Maketext::Extract::Plugin::Mason 1.00
Locale::Maketext::Extract::Plugin::PPI 1.00
Locale::Maketext::Extract::Plugin::Perl 1.00
Locale::Maketext::Extract::Plugin::TT2 1.00
Locale::Maketext::Extract::Plugin::TT2::Directive 1.00
Locale::Maketext::Extract::Plugin::TT2::Parser 1.00
Locale::Maketext::Extract::Plugin::TextTemplate 1.00
Locale::Maketext::Extract::Plugin::TextTemplate::Parser 1.00
Locale::Maketext::Extract::Plugin::YAML 1.00
Locale::Maketext::Extract::Plugin::YAML::Extractor 1.00
Locale::Maketext::Extract::Run 1.00
Locale::Maketext::Lexicon 1.00
Locale::Maketext::Lexicon::Auto 1.00
Locale::Maketext::Lexicon::Gettext 1.00
Locale::Maketext::Lexicon::Msgcat 1.00
Locale::Maketext::Lexicon::Tie 1.00
requirements:
ExtUtils::MakeMaker 6.30
Locale::Maketext 1.17
Module-Build-0.4212
pathname: L/LE/LEONT/Module-Build-0.4212.tar.gz
provides:
Module::Build 0.4212
Module::Build::Base 0.4212
Module::Build::Compat 0.4212
Module::Build::Config 0.4212
Module::Build::Cookbook 0.4212
Module::Build::Dumper 0.4212
Module::Build::Notes 0.4212
Module::Build::PPMMaker 0.4212
Module::Build::Platform::Default 0.4212
Module::Build::Platform::MacOS 0.4212
Module::Build::Platform::Unix 0.4212
Module::Build::Platform::VMS 0.4212
Module::Build::Platform::VOS 0.4212
Module::Build::Platform::Windows 0.4212
Module::Build::Platform::aix 0.4212
Module::Build::Platform::cygwin 0.4212
Module::Build::Platform::darwin 0.4212
Module::Build::Platform::os2 0.4212
Module::Build::PodParser 0.4212
requirements:
CPAN::Meta 2.142060
CPAN::Meta::YAML 0.003
Cwd 0
Data::Dumper 0
ExtUtils::CBuilder 0.27
ExtUtils::Install 0
ExtUtils::Manifest 0
ExtUtils::Mkbootstrap 0
ExtUtils::ParseXS 2.21
File::Basename 0
File::Compare 0
File::Copy 0
File::Find 0
File::Path 0
File::Spec 0.82
File::Temp 0.15
Getopt::Long 0
Module::Metadata 1.000002
Parse::CPAN::Meta 1.4401
Perl::OSType 1
Pod::Man 2.17
Test::Harness 3.16
Test::More 0.49
Text::Abbrev 0
Text::ParseWords 0
perl 5.008000
version 0.87
Mojolicious-6.11
pathname: S/SR/SRI/Mojolicious-6.11.tar.gz
provides:
Mojo undef
Mojo::Asset undef
......@@ -216,9 +317,11 @@ DISTRIBUTIONS
Mojo::Upload undef
Mojo::UserAgent undef
Mojo::UserAgent::CookieJar undef
Mojo::UserAgent::Proxy undef
Mojo::UserAgent::Server undef
Mojo::UserAgent::Transactor undef
Mojo::Util undef
Mojolicious 4.39
Mojolicious 6.11
Mojolicious::Command undef
Mojolicious::Command::cgi undef
Mojolicious::Command::cpanify undef
......@@ -228,7 +331,7 @@ DISTRIBUTIONS
Mojolicious::Command::generate::app undef
Mojolicious::Command::generate::lite_app undef
Mojolicious::Command::generate::makefile undef
Mojolicious::Command::generate::plugin 0.01
Mojolicious::Command::generate::plugin undef
Mojolicious::Command::get undef
Mojolicious::Command::inflate undef
Mojolicious::Command::prefork undef
......@@ -260,18 +363,23 @@ DISTRIBUTIONS
Mojolicious::Sessions undef
Mojolicious::Static undef
Mojolicious::Types undef
Mojolicious::Validator undef
Mojolicious::Validator::Validation undef
Test::Mojo undef
ojo undef
requirements:
ExtUtils::MakeMaker 0
Mojolicious-Plugin-I18N-1.0
pathname: S/SH/SHARIFULN/Mojolicious-Plugin-I18N-1.0.tar.gz
IO::Socket::IP 0.26
Pod::Simple 3.09
Time::Local 1.2
Mojolicious-Plugin-I18N-1.5
pathname: S/SH/SHARIFULN/Mojolicious-Plugin-I18N-1.5.tar.gz
provides:
Mojolicious::Plugin::I18N 1.0
Mojolicious::Plugin::I18N 1.5
requirements:
I18N::LangTags 0.35
Module::Build 0.36
Mojolicious 3
Module::Build 0.42
Mojolicious 5
Test::More 0
perl 5.010001
Net-Domain-TLD-1.70
......
# vim:set sw=4 ts=4 sts=4 ft=perl expandtab:
package Lstu;
use Mojo::Base 'Mojolicious';
use LstuModel;
use Data::Validate::URI qw(is_uri);
use Mojo::JSON;
use Data::Validate::URI qw(is_http_uri is_https_uri);
use Mojo::JSON qw(to_json decode_json);
use Mojo::URL;
$ENV{MOJO_REVERSE_PROXY} = 1;
# This method will run once at server start
sub startup {
my $self = shift;
my $config = $self->plugin('Config');
my $config = $self->plugin('Config' => {
default => {
provisioning => 100,
provis_step => 5,
length => 8,
secret => ['hfudsifdsih'],
page_offset => 10,
}
});
$config->{provisioning} = $config->{provisionning} if (defined($config->{provisionning}));
# Default values
$config->{provisionning} = 100 unless (defined($config->{provisionning}));
$config->{provis_step} = 5 unless (defined($config->{provis_step}));
$config->{length} = 8 unless (defined($config->{length}));
$config->{secret} = ['hfudsifdsih'] unless (defined($config->{secret}));
die "You need to provide a contact information in lstu.conf!" unless (defined($config->{contact}));
$self->plugin('I18N');
$self->secrets($config->{secret});
$self->helper(
provisionning => sub {
provisioning => sub {
my $c = shift;
# Create some short patterns for provisionning
if (LstuModel::Lstu->count('WHERE url IS NULL') < $c->config->{provisionning}) {
for (my $i = 0; $i < $c->config->{provis_step}; $i++) {
# Create some short patterns for provisioning
if (LstuModel::Lstu->count('WHERE url IS NULL') < $c->config('provisioning')) {
for (my $i = 0; $i < $c->config('provis_step'); $i++) {
if (LstuModel->begin) {
my $short;
do {
$short= $c->shortener($c->config->{length});
$short= $c->shortener($c->config('length'));
} while (LstuModel::Lstu->count('WHERE short = ?', $short));
LstuModel::Lstu->create(
......@@ -43,12 +53,25 @@ sub startup {
}
);
$self->helper(
prefix => sub {
my $c = shift;
my $prefix = $c->url_for('index')->to_abs;
# Forced domain
$prefix->host($c->config('fixed_domain')) if (defined($c->config('fixed_domain')));
# Hack for prefix (subdir) handling
$prefix .= '/' if ($c->config('prefix') && $c->config('prefix') ne '/');
return $prefix;
}
);
$self->helper(
shortener => sub {
my $c = shift;
my $length = shift;
my @chars = ('a'..'z','A'..'Z','0'..'9');
my @chars = ('a'..'z','A'..'Z','0'..'9', '-', '_');
my $result = '';
foreach (1..$length) {
$result .= $chars[rand scalar(@chars)];
......@@ -57,78 +80,65 @@ sub startup {
}
);
$self->hook(
after_dispatch => sub {
shift->provisioning();
}
);
$self->hook(
before_dispatch => sub {
my $c = shift;
# API allowed domains
if (defined($c->config('allowed_domains'))) {
if ($c->config('allowed_domains')->[0] eq '*') {
$c->res->headers->header('Access-Control-Allow-Origin' => '*');
} elsif (my $origin = $c->req->headers->origin) {
for my $domain (@{$c->config('allowed_domains')}) {
if ($domain eq $origin) {
$c->res->headers->header('Access-Control-Allow-Origin' => $origin);
last;
}
}
}
}
}
);
# For the first launch (after, this isn't really useful)
$self->provisionning();
$self->provisioning();
# Default layout
$self->defaults(layout => 'default');
# Router
my $r = $self->routes;
# Normal route to controller
$r->get('/' => sub {
my $c = shift;
if (defined($c->flash('format')) && $c->flash('format') eq 'json') {
my %struct;
if (defined($c->flash('msg'))) {
%struct = (
success => Mojo::JSON->false,
msg => $c->flash('msg')
);
} else {
%struct = (
success => Mojo::JSON->true,
short => $c->url_for('/')->to_abs().$c->flash('short'),
url => $c->flash('url')
);
}
$c->render(json => \%struct);
} else {
$c->render(template => 'index');
}
# Check provisionning
$c->on(finish => sub {
shift->provisionning();
});
shift->render(template => 'index');
})->name('index');
my $add = sub {
$r->post('/a' => sub {
my $c = shift;
my $url = $c->param('lsturl');
my $url = Mojo::URL->new($c->param('lsturl'));
my $custom_url = $c->param('lsturl-custom');
my $format = $c->param('format');
my @keys = $c->param;
my @params;
foreach my $key (sort @keys) {
push @params, $key.'='.$c->param($key) unless ($key eq 'lsturl' || $key eq 'lsturl-custom' || ($key eq 'format' && $c->param('format') eq 'json'));
}
$c->flash(format => 'json') if (defined($c->param('format')) && $c->param('format') eq 'json');
$url.= '&'.join('&', @params) if (scalar(@params));
$custom_url = undef if (defined($custom_url) && $custom_url eq '');
if (defined($custom_url) && ($custom_url =~ m/^a$/ || $custom_url !~ m/^[-a-zA-Z0-9_]+$/)) {
$c->flash(
msg => $c->l('no_valid_shorcut', $url)
);
my ($msg, $short);
if (defined($custom_url) && ($custom_url =~ m/^a(pi)?|stats$/ || $custom_url !~ m/^[-a-zA-Z0-9_]+$/)) {
$msg = $c->l('The shortened text can contain only numbers, letters and the - and _ characters and can\'t be "a", "api" or "stats". Your URL to shorten: [_1]', $url);
} elsif (defined($custom_url) && LstuModel::Lstu->count('WHERE short = ?', $custom_url) > 0) {
$c->flash(
msg => $c->l('already_taken', $custom_url)
);
} elsif (is_uri($url)) {
my $short;
$msg = $c->l('The shortened text ([_1]) is already used. Please choose another one.', $custom_url);
} elsif (is_http_uri($url->to_string) || is_https_uri($url->to_string)) {
my @records = LstuModel::Lstu->select('WHERE url = ?', $url);
if (scalar(@records) && !defined($custom_url)) {
# Already got this URL
$c->flash(
short => $records[0]->short,
url => $url
);
$short = $records[0]->short;
} else {
if(LstuModel->begin) {
if (defined($custom_url)) {
......@@ -138,10 +148,8 @@ sub startup {
counter => 0,
timestamp => time()
);
$c->flash(
short => $custom_url,
url => $url
);
$short = $custom_url;
} else {
@records = LstuModel::Lstu->select('WHERE url IS NULL LIMIT 1');
if (scalar(@records)) {
......@@ -151,36 +159,122 @@ sub startup {
timestamp => time()
);
$c->flash(
short => $records[0]->short,
url => $url
);
$short = $records[0]->short;
} else {
# Houston, we have a problem
$c->flash(
msg => $c->l('no_more_short', $c->config->{contact}, $url)
);
$msg = $c->l('No shortened URL available. Please retry or contact the administrator at [_1]. Your URL to shorten: [_2]', $c->config('contact'), $url);
}
}
LstuModel->commit;
}
}
} else {
$c->flash(
msg => $c->l('no_valid_url', $url)
$msg = $c->l('[_1] is not a valid URL.', $url);
}
if ($msg) {
$c->respond_to(
json => { json => { success => Mojo::JSON->false, msg => $msg } },
any => sub {
my $c = shift;
$c->flash('msg' => $msg);
$c->redirect_to('index');
}
);
} else {
# Get URLs from cookie
my $u = (defined($c->cookie('url'))) ? decode_json $c->cookie('url') : [];
# Add the new URL
push @{$u}, $short;
# Make the array contain only unique URLs
my %k = map { $_, 1 } @{$u};
@{$u} = keys %k;
# And set the cookie
my $cookie = to_json($u);
$c->cookie('url' => $cookie, {expires => time + 142560000}); # expires in 10 years
my $prefix = $c->prefix;
$c->respond_to(
json => { json => { success => Mojo::JSON->true, url => $url, short => $prefix.$short } },
any => sub {
my $c = shift;
$c->flash('url' => $url);
$c->flash('short' => $prefix.$short);
$c->redirect_to('index');
}
);
}
$c->redirect_to('/');
})->name('add');
# Check provisionning
$c->on(finish => sub {
shift->provisionning();
});
};
$r->get('/api' => sub {
shift->render(
template => 'api'
);
})->name('api');
$r->post('/a' => $add)->name('add');
$r->get('/stats' => sub {
my $c = shift;
my $u = (defined($c->cookie('url'))) ? decode_json $c->cookie('url') : [];
my $p = join ",", (('?') x @{$u});
my @urls = LstuModel::Lstu->select("WHERE short IN ($p) ORDER BY counter DESC", @{$u});
my $prefix = $c->prefix;
$r->get('/a' => $add)->name('add');
$c->respond_to(
json => sub {
my @struct;
for my $url (@urls) {
push @struct, {
short => $prefix.$url->{short},
url => $url->{url},
counter => $url->{counter},
created_at => $url->{timestamp}
};
}
$c->render( json => \@struct );
},
any => sub {
shift->render(
template => 'stats',
prefix => $prefix,
urls => \@urls
)
}
)
})->name('stats');
$r->post('/stats' => sub {
my $c = shift;
my $pwd = $c->param('adminpwd');
my $page = $c->param('page') || 0;
$page = 0 if ($page < 0);
if (defined($c->config('adminpwd')) && $pwd eq $c->config('adminpwd')) {
my $total = LstuModel::Lstu->count("WHERE url IS NOT NULL");
$page = $page - 1 if ($page * $c->config('page_offset') > $total);
my ($first, $last) = (!$page, ($page * $c->config('page_offset') <= $total && $total < ($page + 1) * $c->config('page_offset')));
my @urls = LstuModel::Lstu->select("WHERE url IS NOT NULL ORDER BY counter DESC LIMIT ? offset ?", $c->config('page_offset'), $page * $c->config('page_offset'));
$c->render(
template => 'stats',
prefix => $c->prefix,
urls => \@urls,
first => $first,
last => $last,
page => $page,
adminpwd => $pwd,
total => $total
)
} else {
$c->flash('msg' => $c->l('Bad password'));
$c->redirect_to('stats');
}
});
$r->get('/:short' => sub {
my $c = shift;
......@@ -188,24 +282,33 @@ sub startup {
my @urls = LstuModel::Lstu->select('WHERE short = ?', $short);
if (scalar(@urls)) {
$c->res->code(301);
$c->redirect_to($urls[0]->url);
# Update counter and check provisionning
my $url = $urls[0]->url;
$c->respond_to(
json => { json => { success => Mojo::JSON->true, url => $url } },
any => sub {
my $c = shift;
$c->res->code(301);
$c->redirect_to($url);
}
);
# Update counter
$c->on(finish => sub {
my $counter = $urls[0]->counter + 1;
$urls[0]->update (counter => $counter);
shift->provisionning();
});
} else {
$c->flash(
msg => $c->l('url_not_found', $c->url_for('/')->to_abs.$short)
my $msg = $c->l('The shortened URL [_1] doesn\'t exist.', $c->url_for('/')->to_abs.$short);
$c->respond_to(
json => { json => { success => Mojo::JSON->false, msg => $msg } },
any => sub {
my $c = shift;
$c->flash('msg' => $msg);
$c->redirect_to('index');
}
);
$c->redirect_to('/');
}
})->name('short');
}
1;
package Lstu::I18N;
use base 'Locale::Maketext';
use File::Basename qw/dirname/;
use Locale::Maketext::Lexicon {
_auto => 1,
_decode => 1,
'*' => [Gettext => dirname(__FILE__) . '/I18N/*.po']
};
1;
package Lstu::I18N::en;
use Mojo::Base 'Lstu::I18N';
our %Lexicon = (
'about' => 'About',
'already_taken' => 'The shortened text ([_1]) is already used. Please choose another one.',
'api' => 'Fast way to shorten an URL:',
'custom-url' => 'Custom shortened text',
'go' => 'Go !',
'license' => 'License:',
'no_more_short' => 'No shortened URL available. Please retry or contact the administrator at [_1]. Your URL to shorten: [_2]',
'no_valid_shorcut' => 'The shortened text can contain only numbers, letters and the - and _ characters and can\'t be "a".',
'no_valid_url' => '[_1] is not a valid URL.',
'url_not_found' => 'The shortened URL [_1] doesn\'t exist.',
'url_to_shorten' => 'URL to shorten',
);
1;
# Lstu
# Copyright (C) 2013 - 2015 Luc Didry
# This file is distributed under the same license as the Lstu package.
# Luc Didry <luc@didry.org>, 2015.
#
msgid ""
msgstr ""
"Project-Id-Version: Lstu - 0.04\n"
"POT-Creation-Date: YEAR-MO-DA HO:MI+ZONE\n"
"PO-Revision-Date: 2015-05-28 23:35+0100\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Last-Translator: Luc Didry <luc@didry.org>\n"
"Language-Team: Luc Didry <admin@lstu.fr>\n"
"Language: en\n"
"X-Generator: Poedit 1.7.7\n"
"X-Poedit-SourceCharset: UTF-8\n"
#. ($url)