The Perl Toolchain Summit 2025 Needs You: You can help 🙏 Learn more

# $Id: Config.pm 539 2013-12-09 22:28:11Z oetiker $
=head1 NAME
CallBackery::Config - get parse configuration file for CallBackery
=head1 SYNOPSIS
use Nq::Config;
my $cfg = CallBackery::Config->new(file=>$file);
my $hash_ref = $cfg->cfgHash();
my $pod = $cfg->pod();
=head1 DESCRIPTION
CallBackery gets much of its configuration from this config file.
=cut
use Mojo::Base -base,-async_await, -signatures;
use CallBackery::Exception qw(mkerror);
use Carp;
use autodie;
use Mojo::Loader qw(load_class);
use Mojo::JSON qw(true false);
use Scalar::Util qw(blessed);
# use Devel::Cycle;
=head2 file
the name of the config file
=cut
has file => sub { croak "the file parameter is mandatory" };
has secretFile => sub ($self) {
my $secretFile = $self->file.'.secret';
if (not -f $secretFile){
open my $rand, '>', $secretFile;
chmod 0600,$secretFile;
print $rand sprintf('%x%x',int(rand()*1e14),int(rand()*1e14));
close $rand;
chmod 0400,$secretFile;
}
return $secretFile;
};
has app => sub { croak "the app parameter is mandatory" }, weak => 1;
has log => sub {
shift->app->log;
};
=head2 cfgHash
a hash containing the data from the config file
=cut
has cfgHash => sub {
my $self = shift;
my $cfg_file = shift;
my $parser = $self->makeParser();
my $cfg = $parser->parse($self->file, {encoding => 'utf8'}) or croak($parser->{err});
# the grammar is self referential, so we need to clean it up
$self->grammar(undef);
return $cfg;
};
=head2 pod
returns a pod documenting the config file
=cut
has pod => sub {
my $self = shift;
my $parser = $self->makeParser();
my $E = '=';
my $footer = <<"FOOTER";
${E}head1 COPYRIGHT
Copyright (c) 2014 by OETIKER+PARTNER AG. All rights reserved.
${E}head1 AUTHOR
S<Tobias Oetiker E<lt>tobi\@oetiker.chE<gt>>
S<Fritz Zaucker E<lt>fritz.zaucker\@oetiker.chE<gt>>
${E}head1 HISTORY
2014-01-11 to 1.0 first version
2014-04-29 fz 1.1 implement plugin path
FOOTER
my $header = <<"HEADER";
${E}head1 NAME
callbackery.cfg - The Appliance FRONTEND Builder config file
${E}head1 SYNOPSIS
*** BACKEND ***
log_file = /tmp/nw-tobi.log
*** FRONTEND ***
logo = logo.png
spinner = myspinner.gif
logo_small = logo-small.png
title = Appliance Configurator
${E}head1 DESCRIPTION
The afb.cfg provides all the info for afb and its gui modules to interact with your appliance.
${E}head1 CONFIGURATION
HEADER
return $header.$parser->makepod().$footer;
};
=head2 pluginPath
array of name spaces to look for gui plugins
=cut
has pluginPath => sub { ['CallBackery::GuiPlugin']; };
=head2 B<loadAndNewPlugin>('PluginModule')
Find the given module in the F<pluginPath>, load it and create a first instance.
=cut
sub loadAndNewPlugin {
my $self = shift;
my $plugin = shift;
my $module;
my $ok;
for my $path (@{$self->pluginPath}) {
#$self->log->debug("looking for $plugin in $path");
if (my $e = load_class "${path}::$plugin") {
die mkerror(3894,"Loading ${path}::$plugin: $e") if ref $e;
} else {
my $proto = "${path}::${plugin}"->new(log=>$self->log);
$proto->{prototype} = 1;
return $proto;
}
}
die mkerror(123, "Plugin Module $plugin not found");
};
has grammar => sub {
my $self = shift;
my $pluginList = {};
my $pluginPath = $self->pluginPath;
for my $path (@INC){
for my $pPath (@$pluginPath) {
my @pDirs = split /::/, $pPath;
my $fPath = File::Spec->catdir($path, @pDirs, '*.pm');
for my $file (glob($fPath)) {
my ($volume, $modulePath, $moduleName) = File::Spec->splitpath($file);
$moduleName =~ s{\.pm$}{};
$pluginList->{$moduleName} = 'Plugin Module';
}
}
}
return {
_sections => [ qw(BACKEND FRONTEND FRONTEND-COLORS /PLUGIN:\s*\S+/)],
_mandatory => [qw(BACKEND FRONTEND)],
BACKEND => {
_doc => 'BACKEND Settings',
_vars => [ qw(log_file cfg_db sesame_user sesame_pass) ],
_mandatory => [ qw(cfg_db sesame_user sesame_user) ],
log_file => { _doc => 'write a log file to this location (unless in development mode)'},
cfg_db => { _doc => 'file to store the config database'},
sesame_user => { _doc => <<'DOC'},
In Open Sesame mode, one has to use this username to get access to the system.
The password you enter does not matter.
DOC
sesame_pass => { _doc => <<'DOC'},
Using sesame_user and sesame_pass, the system can always be accessed.
In default configuration sesame_pass is NOT set.
DOC
},
FRONTEND => {
_doc => 'Settings for the Web FRONTEND',
_vars => [ qw(logo logo_small logo_noscale spinner title initial_plugin company_name company_url company_support
hide_password hide_password_icon hide_release hide_company max_width
)
],
logo => {
_doc => 'url for the logo brand the login sceen',
},
company_name => {
_doc => 'who created the app',
},
company_url => {
_doc => 'link to the company homepage'
},
max_width => {
_doc => 'maximum content width'
},
company_support => {
_doc => 'company support eMail'
},
logo_small => {
_doc => 'url for the small logo brand the UI',
},
logo_noscale => {
_doc => "don't scale logo on login window",
_re => '(yes|no|true|false)',
_re_error => 'pick yes or no OR true or false',
_sub => sub {
$_[0] = ($_[0] =~ /yes|true/) ? true : false;
return;
},
},
spinner => {
_doc => 'url for the busy animation spinner gif',
},
title => {
_doc => 'title string for the application'
},
initial_plugin => {
_doc => 'which tab should be active upon login ?'
},
hide_password => {
_doc => 'hide password field on login screen',
_re => '(yes|no|true|false)',
_re_error => 'pick yes or no OR true or false',
_sub => sub {
$_[0] = ($_[0] =~ /yes|true/) ? true : false;
return;
},
},
hide_password_icon => {
_doc => 'hide password icon on login screen',
_re => '(yes|no|true|false)',
_re_error => 'pick yes or no OR true or false',
_sub => sub {
$_[0] = ($_[0] =~ /yes|true/) ? true : false;
return;
},
},
hide_release => {
_doc => 'hide release string on login screen',
_re => '(yes|no|true|false)',
_re_error => 'pick yes or no OR true or false',
_sub => sub {
$_[0] = ($_[0] =~ /yes|true/) ? true : false;
return;
},
},
hide_company => {
_doc => 'hide company string on login screen',
_re => '(yes|no|true|false)',
_re_error => 'pick yes or no OR true or false',
_sub => sub {
$_[0] = ($_[0] =~ /yes|true/) ? true : false;
return;
},
},
},
'FRONTEND-COLORS' => {
_vars => [ '/[a-zA-Z]\S+/' ],
'/[a-zA-Z]\S+/' => {
_doc => <<COLORKEYS_END,
Use this section to override any color key used in the qooxdoo simple theme as well as the following:
C<tabview-page-background>,
C<tabview-page-border>,
C<tabview-button-background>,
C<tabview-button-checked-background>,
C<tabview-button-text>,
C<tabview-button-checked-text>,
C<tabview-button-border>,
C<tabview-button-checked-border>.
C<textfield-readonly>.
The keys can be set to standard web colors C<rrggbb> or to other key names.
COLORKEYS_END
_example => <<EXAMPLE_END,
ff0000
EXAMPLE_END
_sub => sub {
if ($_[0] =~ /^\s*([0-9a-f]{3,6})\s*$/i){
$_[0] = '#'.lc($1);
}
return undef;
}
}
},
'/PLUGIN:\s*\S+/' => {
_order => 1,
_doc => 'Plugins providing appliance specific funtionality',
_vars => [qw(module)],
_mandatory => [qw(module)],
module => {
_sub => sub {
eval {
$_[0] = $self->loadAndNewPlugin($_[0]);
};
if ($@){
return "Failed to load Plugin $_[0]: $@";
}
return undef;
},
_dyn => sub {
my $var = shift;
my $module = shift;
$module = $self->loadAndNewPlugin($module) if not ref $module;
my $tree = shift;
my $grammar = $module->grammar();
push @{$grammar->{_vars}}, 'module';
for my $key (keys %$grammar){
$tree->{$key} = $grammar->{$key};
}
},
_dyndoc => $pluginList,
},
}
};
};
sub makeParser {
my $self = shift;
my $parser = Config::Grammar::Dynamic->new($self->grammar);
return $parser;
}
=head2 getTranslations
Load translations from po files
=cut
sub getTranslations {
my $self = shift;
my $cfg = shift || {};
my %lx;
my $path = $cfg->{path} // $self->app->home->rel_file("share");
my $po = new Locale::PO();
for my $file (glob(File::Spec->catdir($path, '*.po'))) {
my ($volume, $localePath, $localeName) = File::Spec->splitpath($file);
my $locale = $localeName;
$locale =~ s/\.po$//;
my $lang = $locale;
$lang =~ s/_.+//;
local $_; # since load_file_ashash modifies $_ and does not localize it
my $href = Locale::PO->load_file_ashash($file, 'utf8');
for my $key (keys %$href) {
my $o = $href->{$key};
my $id = $po->dequote($o->msgid);
my $str = $po->dequote($o->msgstr);
next unless $id;
$lx{$locale}{$id} = $str;
}
}
return \%lx;
}
=head2 postProcessCfg
Post process the configuration data into a format that is easily used
by the application.
=cut
sub postProcessCfg {
my $self = shift;
my $cfg = $self->cfgHash;
# only postprocess once
return $cfg if $cfg->{PLUGIN}{list};
my %plugin;
my @pluginOrder;
for my $section (sort keys %$cfg){
my $sec = $cfg->{$section};
next unless ref $sec eq 'HASH'; # skip non hash stuff
for my $key (keys %$sec){
next unless ref $sec->{$key} eq 'HASH' and $sec->{$key}{_text};
$sec->{$key} = $sec->{$key}{_text};
}
if ($section =~ /^PLUGIN:\s*(.+)/){
my $name = $1;
$pluginOrder[$sec->{_order}] = $name;
delete $sec->{_order};
my $obj = $cfg->{PLUGIN}{prototype}{$name} = $sec->{module};
delete $sec->{module};
$obj->config($sec);
$obj->name($name);
$obj->app($self->app);
$obj->massageConfig($cfg);
# cleanup the config
delete $cfg->{$section};
}
$cfg->{PLUGIN}{list} = \@pluginOrder;
}
# rename section
# delete returns the value of the deleted hash element
if (exists $cfg->{'FRONTEND-COLORS'}) {
$cfg->{FRONTEND}{COLORS} = $cfg->{'FRONTEND-COLORS'};
delete $cfg->{'FRONTEND-COLORS'};
}
$cfg->{FRONTEND}{TRANSLATIONS} = $self->getTranslations();
return $cfg;
}
=head2 instantiatePlugin(pluginName,userObj,args)
create a new instance of this plugin prototype
=cut
sub _getPluginObject {
my $self = shift;
my $name = shift;
my $user = shift;
my $args = shift;
my $prototype = $self->cfgHash->{PLUGIN}{prototype}{$name};
# clean the name
$name =~ s/[^-_0-9a-z]/_/gi;
die mkerror(39943,"No prototype for $name")
if not defined $prototype;
my $obj = $prototype->new(
user => $user,
name => $prototype->name,
config => $prototype->config,
args => $args // {},
app => $self->app,
);
$obj->log; # make sure logging is initialized
return $obj;
}
# do not (!!!) implement this with async/await as it causes the the generated
# object to be somehow get a problem with reference counting with prevents
# timely destruction of the object 2024-06-12 tobi
sub instantiatePlugin_p {
my $self = shift;
my $obj = $self->_getPluginObject(@_);
return $self->promisify($obj->checkAccess)->then(sub {
my $access = shift;
return $obj if $access;
my $name = $obj->name;
Mojo::Promise->reject(mkerror(39944,"No permission to access $name"));
});
}
sub instantiatePlugin {
my $self = shift;
my @args = @_;
my $obj = $self->_getPluginObject(@args);
my $name = $obj->name;
die mkerror(39944,"No permission to access $name")
if not $self->promiseDeath($obj->checkAccess);
return $obj;
}
=head2 $configBlob = $cfg->getConfigBlob()
return the configuration state of the system as a blob
=cut
has configPlugins => sub {
my $self = shift;
my $user = $self->app->userObject->new(app=>$self->app,userId=>'__CONFIG', log=>$self->log);
my $cfg = $self->cfgHash;
my @plugins;
for my $name (@{$cfg->{PLUGIN}{list}}){
my $obj = eval {
$self->instantiatePlugin($name,$user);
} or next;
push @plugins, $obj;
}
return \@plugins;
};
sub getCrypt {
require Crypt::Rijndael;
my $self = shift;
my $password = substr((shift || '').('x' x 32),0,32);
return Crypt::Rijndael->new( $password,Crypt::Rijndael::MODE_CBC() );
}
sub pack16 {
my $self = shift;
my $string = shift;
my $len = length($string);
my $mod = 16 - ($len % 16);
return sprintf("%016x%s",$len,$string.('x' x $mod));
}
sub unpack16 {
my $self = shift;
my $string = shift;
my $len = substr($string,0,16);
if ( $len !~ /^[0-9a-f]{16}$/ or hex($len) > length($string)-16 ){
die mkerror(3844,trm("Wrong password!"));
}
return substr($string,16,hex($len));
}
sub getConfigBlob {
my $self = shift;
my $password = shift;
require Archive::Zip;
my $zip = Archive::Zip->new();
my $cfg = $self->cfgHash;
# flush all the changes in the database to the db file
my $dumpfile = '/tmp/cbdump'.$$;
unlink $dumpfile if -f $dumpfile;
open my $dump, '|-','/usr/bin/sqlite3',$cfg->{BACKEND}{cfg_db};
print $dump ".output $dumpfile\n";
print $dump ".dump\n";
close $dump;
$zip->addFile({
filename => $dumpfile,
zipName => '{DATABASEDUMP}',
});
for my $obj (@{$self->configPlugins}){
my $name = $obj->name;
for my $file (@{$obj->stateFiles}) {
if (-r $file){
$zip->addFile({
filename => $file,
zipName => '{PLUGINSTATE.'.$name.'}'.$file
})
}
}
}
my $zipData;
open(my $fh, ">", \$zipData);
$zip->writeToFileHandle($fh,0);
my $crypt = $self->getCrypt($password);
return $crypt->encrypt($self->pack16($zipData));
}
=head2 $cfg->restoreConfigBlob(configBlob)
retore the confguration state
=cut
sub restoreConfigBlob {
my $self = shift;
my $config = shift;
my $password = shift;
require Archive::Zip;
my $crypt = $self->getCrypt($password);
$config = $self->unpack16($crypt->decrypt($config));
my $cfg = $self->cfgHash;
my $user = $self->app->userObject->new(app=>$self->app,userId=>'__CONFIG', log=>$self->log);
open my $fh ,'<', \$config;
my $zip = Archive::Zip->new();
$zip->readFromFileHandle($fh);
my %stateFileCache;
for my $member ($zip->members){
for ($member->fileName){
/^\{DATABASE\}$/ && do {
$self->log->warn("Restoring Database!");
$self->app->database->mojoSqlDb->disconnect;
unlink glob $cfg->{BACKEND}{cfg_db}.'*';
$member->extractToFileNamed($cfg->{BACKEND}{cfg_db});
last;
};
/^\{DATABASEDUMP\}$/ && do {
$self->log->warn("Restoring Database Dump!");
$self->app->database->mojoSqlDb->disconnect;
unlink glob $cfg->{BACKEND}{cfg_db}.'*';
open my $sqlite, '|-', '/usr/bin/sqlite3',$cfg->{BACKEND}{cfg_db};
my $sql = $member->contents();
$sql =~ s/0$//; # for some reason the dump ends in 0
print $sqlite $sql;
close $sqlite;
last;
};
m/^\{PLUGINSTATE\.([^.]+)\}(.+)/ && do {
my $plugin = $1;
my $file = $2;
if (not $stateFileCache{$plugin}){
my $obj = eval {
$self->instantiatePlugin($plugin,$user);
};
if (not $obj){
$self->log->warn("Ignoring $file from plugin $plugin since the plugin is not available here.");
next;
}
$stateFileCache{$plugin} = { map { $_ => 1 } @{$obj->stateFiles} };
};
if ($stateFileCache{$plugin}{$file}){
$member->extractToFileNamed($file);
}
else {
$self->log->warn("Ignoring $file from archive since it is not listed in $plugin stateFiles.");
}
}
}
}
$self->reConfigure;
}
=head2 $cfg->reConfigure()
Regenerate all the template based configuration files using input from the database.
=cut
sub reConfigure {
my $self = shift;
my $secretFile = $self->secretFile;
if (not -f $secretFile){
open my $rand, '>', $secretFile;
chmod 0600,$secretFile;
print $rand sprintf('%x%x',int(rand()*1e14),int(rand()*1e14));
close $rand;
chmod 0400,$secretFile;
}
for my $obj (@{$self->configPlugins}){
$obj->reConfigure;
}
}
=head2 $cfg->unConfigure()
Restore the system to unconfigured state. By removing the
configuration database, unlinking all user supplied configuration
files and regenerating all template based configuration files with
empty input.
=cut
sub unConfigure {
no autodie;
my $self = shift;
my $cfg = $self->cfgHash;
$self->log->debug("unlinking config database ".$cfg->{BACKEND}{cfg_db});
unlink $cfg->{BACKEND}{cfg_db} if -f $cfg->{BACKEND}{cfg_db};
open my $gen, '>', $cfg->{BACKEND}{cfg_db}.'.flush';
close $gen;
#get 'clean' config files
$self->reConfigure();
# and now remove all state
for my $obj (@{$self->configPlugins}){
for my $file (@{$obj->stateFiles},@{$obj->unConfigureFiles}) {
next if not -f $file;
$self->log->debug('['.$obj->name."] unlinking $file");
unlink $file;
}
}
unlink $cfg->{BACKEND}{log_file} if defined $cfg->{BACKEND}{log_file} and -f $cfg->{BACKEND}{log_file} ;
unlink $self->secretFile if -f $self->secretFile;
system "sync";
}
=head2 $cfg->promisify(xxx)
always return a promise resolving to the value
=cut
sub promisify {
my $self = shift;
my $value = shift;
if (eval { blessed $value && $value->isa('Mojo::Promise') }){
return $value;
}
return Mojo::Promise->resolve($value);
}
=head2 $cfg->promiseDeath(xxx)
die when there is a promise response
=cut
sub promiseDeath {
my $self = shift;
my $value = shift;
if (eval { blessed $value && $value->isa('Mojo::Promise') }){
Mojo::Exception->throw("unexpected promise respone!");
}
return $value;
}
1;
__END__
=head1 COPYRIGHT
Copyright (c) 2014 by OETIKER+PARTNER AG. All rights reserved.
=head1 AUTHOR
S<Tobias Oetiker E<lt>tobi@oetiker.chE<gt>>
S<Fritz Zaucker E<lt>fritz.zaucker@oetiker.chE<gt>>
=head1 HISTORY
2014-01-11 to 1.0 first version
2014-04-29 fz 1.1 implement plugin path
2020-11-20 fz 1.2 call postProcessCfg from CallBackery.pm
=cut
# Emacs Configuration
#
# Local Variables:
# mode: cperl
# eval: (cperl-set-style "PerlStyle")
# mode: flyspell
# mode: flyspell-prog
# End:
#
# vi: sw=4 et