File Coverage

blib/lib/App/cpanel.pm
Criterion Covered Total %
statement 46 171 26.9
branch 2 74 2.7
condition 1 8 12.5
subroutine 14 43 32.5
pod 3 23 13.0
total 66 319 20.6


line stmt bran cond sub pod time code
1             package App::cpanel;
2              
3 1     1   68944 use Exporter 'import';
  1         8  
  1         71  
4              
5             our $VERSION = '0.005';
6             our @EXPORT_OK = qw(dispatch_cmd_print dispatch_cmd_raw_p dir_walk_p);
7              
8             =head1 NAME
9              
10             App::cpanel - CLI for cPanel UAPI and API 2
11              
12             =begin markdown
13              
14             # PROJECT STATUS
15              
16             [![CPAN version](https://badge.fury.io/pl/App-cpanel.svg)](https://metacpan.org/pod/App::cpanel)
17              
18             =end markdown
19              
20             =head1 SYNOPSIS
21              
22             $ cpanel uapi Notifications get_notifications_count
23             $ cpanel uapi ResourceUsage get_usages
24             $ cpanel uapi Fileman list_files dir=public_html
25             $ cpanel uapi Fileman get_file_content dir=public_html file=index.html
26             $ cpanel download public_html/index.html
27             $ cpanel api2 Fileman fileop op=chmod metadata=0755 sourcefiles=public_html/cgi-bin/hello-world
28             $ cpanel api2 Fileman fileop op=unlink sourcefiles=public_html/cgi-bin/hello-world
29             $ cpanel api2 Fileman mkdir path= name=new-dir-at-top
30              
31             # this one is one at a time but can overwrite files
32             $ cpanel api2 Fileman savefile dir=public_html/cgi-bin filename=hello-world content="$(cat public_html/cgi-bin/hello-world)"
33             # this is multiple files but refuses to overwrite
34             $ cpanel upload public_html/cgi-bin hello-world
35              
36             # download
37             $ cpanel mirror public_html public_html cpanel localfs
38             # upload
39             $ cpanel mirror public_html public_html localfs cpanel
40              
41             =head1 DESCRIPTION
42              
43             CLI for cPanel UAPI and also API 2, due to missing functionality in UAPI.
44              
45             Stores session token in F<~/.cpanel-token>, a two-line file. First line
46             is the URL component that goes after C. Second is the C
47             cookie, which you can get from your browser's DevTools.
48              
49             Stores relevant domain name in F<~/.cpanel-domain>.
50              
51             =head1 FUNCTIONS
52              
53             Exportable:
54              
55             =head2 dispatch_cmd_print
56              
57             Will print the return value, using L except for
58             C.
59              
60             =head2 dispatch_cmd_raw_p
61              
62             Returns a promise of the decoded JSON value or Ced content.
63              
64             =head2 dir_walk_p
65              
66             Takes C<$from_dir>, C<$to_dir>, C<$from_map>, C<$to_map>. Copies the
67             information in the first directory to the second, using the respective
68             maps. Assumes UNIX-like semantics in filenames, i.e. C<$dir/$file>.
69              
70             Returns a promise of completion.
71              
72             The maps are hash-refs whose values are functions, and the keys are:
73              
74             =head3 ls
75              
76             Takes C<$dir>. Returns a promise of two hash-refs, of directories and of
77             files. Each has keys of relative filename, values are an array-ref
78             containing a string octal number representing UNIX permissions, and a
79             number giving the C. Must reject if does not exist.
80              
81             =head3 mkdir
82              
83             Takes C<$dir>. Returns a promise of having created the directory.
84              
85             =head3 read
86              
87             Takes C<$dir>, C<$file>. Returns a promise of the file contents.
88              
89             =head3 write
90              
91             Takes C<$dir>, C<$file>. Returns a promise of having written the file
92             contents.
93              
94             =head3 chmod
95              
96             Takes C<$path>, C<$perms>. Returns a promise of having changed the
97             permissions.
98              
99             =head1 SEE ALSO
100              
101             L
102              
103             L
104              
105             =head1 AUTHOR
106              
107             Ed J
108              
109             =head1 COPYRIGHT AND LICENSE
110              
111             This is free software; you can redistribute it and/or modify it under
112             the same terms as the Perl 5 programming language system itself.
113              
114             =cut
115              
116 1     1   5 use strict;
  1         2  
  1         19  
117 1     1   5 use warnings;
  1         1  
  1         36  
118 1     1   618 use Mojo::URL;
  1         203344  
  1         9  
119 1     1   606 use Mojo::UserAgent;
  1         264774  
  1         10  
120 1     1   50 use Mojo::File qw(path);
  1         2  
  1         53  
121 1     1   8 use Mojo::Util qw(dumper encode);
  1         2  
  1         3284  
122              
123             my %cmd2func = (
124             uapi => [ \&uapi_p, 1 ],
125             download => [ \&download_p, 0 ],
126             upload => [ \&upload_p, 1 ],
127             api2 => [ \&api2_p, 1 ],
128             mirror => [ \&mirror_p, 1 ],
129             );
130             my $token_file = "$ENV{HOME}/.cpanel-token";
131             my $domain_file = "$ENV{HOME}/.cpanel-domain";
132             my %localfs_map = (
133             ls => \&localfs_ls,
134             mkdir => \&localfs_mkdir,
135             read => \&localfs_read,
136             write => \&localfs_write,
137             chmod => \&localfs_chmod,
138             );
139             my %cpanel_map = (
140             ls => \&cpanel_ls,
141             mkdir => \&cpanel_mkdir,
142             read => \&cpanel_read,
143             write => \&cpanel_write,
144             chmod => \&cpanel_chmod,
145             );
146             our %MAP2HASH = (
147             localfs => \%localfs_map,
148             cpanel => \%cpanel_map,
149             );
150              
151             sub dispatch_cmd_print {
152 0     0 1 0 my $cmd = shift;
153 0 0       0 die "No command\n" unless $cmd;
154 0 0       0 die "Unknown command '$cmd'\n" unless my $info = $cmd2func{$cmd};
155 0         0 my $p = dispatch_cmd_raw_p($cmd, @_);
156 0 0       0 $p = $p->then(\&dumper) if $info->[1];
157 0     0   0 $p->then(sub { print @_ }, sub { warn encode 'UTF-8', join '', @_ })->wait;
  0         0  
  0         0  
158             }
159              
160             sub dispatch_cmd_raw_p {
161 0     0 1 0 my $cmd = shift;
162 0 0       0 die "No command\n" unless $cmd;
163 0 0       0 die "Unknown command '$cmd'\n" unless my $info = $cmd2func{$cmd};
164 0         0 goto &{$info->[0]};
  0         0  
165             }
166              
167             sub api_request {
168 0     0 0 0 my ($method, $domain, $token, $parts, $args, @extra_args) = @_;
169 0         0 my ($url_token, $cookie_token) = split /\s+/, $token;
170 0         0 my $url = Mojo::URL->new("https://$domain:2083");
171 0         0 $url->path(join '/', '', "cpsess$url_token", @$parts);
172 0 0       0 $url->query(%$args) if $args;
173 0         0 CORE::state $ua = Mojo::UserAgent->new; # state as needs to live long enough to complete request
174 0         0 $ua->$method(
175             $url->to_abs . "",
176             { Cookie => "cpsession=$cookie_token" },
177             @extra_args,
178             );
179             }
180              
181             sub read_file {
182 0     0 0 0 my ($file) = @_;
183 0 0       0 die "$file: $!\n" unless -f $file;
184 0 0       0 die "$file: too readable\n" if (stat $file)[2] & 0044;
185 0         0 local $/;
186 0 0       0 open my $fh, $file or die "$file: $!\n";
187 0         0 my $content = <$fh>;
188 0 0       0 die "No content in '$file'\n" unless $content;
189 0         0 $content =~ s/^\s*(.*?)\s*$/$1/g;
190 0         0 $content;
191             }
192              
193 0     0 0 0 sub read_token { read_file($token_file) }
194 0     0 0 0 sub read_domain { read_file($domain_file) }
195              
196             sub _error_or_json {
197 0     0   0 my $res = $_[0]->res;
198 0 0       0 die $res->code . ": " . $res->message . "\n" if $res->code != 200;
199 0         0 $res->json;
200             }
201              
202             sub _uapi_error_or_json {
203 0     0   0 my $json = $_[0];
204 0 0       0 if (!$json->{status}) {
205             die join '', "Failed:\n",
206 0 0       0 map "$_\n", map @{ $json->{$_} || [] }, qw(errors warnings);
  0         0  
207             }
208 0         0 $json;
209             }
210              
211             sub uapi_p {
212 0     0 0 0 my ($module, $function, @args) = @_;
213 0 0       0 die "No module\n" unless $module;
214 0 0       0 die "No function\n" unless $function;
215 0         0 my ($token, $domain) = (read_token(), read_domain());
216 0 0       0 my $args_hash = ref($args[0]) eq 'HASH'
    0          
217             ? $args[0]
218             : { map split('=', $_, 2), @args }
219             if @args;
220 0         0 my $tx_p = api_request 'get_p', $domain, $token,
221             [ 'execute', $module, $function ],
222             $args_hash;
223 0         0 $tx_p->then(\&_error_or_json)->then(\&_uapi_error_or_json);
224             }
225              
226             sub download_p {
227 0     0 0 0 my ($file) = @_;
228 0 0       0 die "No file\n" unless $file;
229 0         0 my ($token, $domain) = (read_token(), read_domain());
230 0         0 my $tx_p = api_request 'get_p', $domain, $token,
231             [ 'download' ],
232             { skipencode => 1, file => $file };
233             $tx_p->then(sub {
234 0     0   0 my $res = $_[0]->res;
235 0 0       0 die $res->code . ": " . $res->message . "\n" if $res->code != 200;
236 0         0 $res->body;
237 0         0 });
238             }
239              
240             sub make_upload_form {
241 0     0 0 0 my $dir = shift;
242 0         0 my $counter = 0;
243             +{
244             dir => $dir,
245             map {
246 0         0 my $p = path $_;
  0         0  
247 0         0 ('file-' . ++$counter => {
248             filename => $p->basename,
249             content => $p->slurp,
250             });
251             } @_,
252             };
253             }
254              
255             sub upload_p {
256 0     0 0 0 my ($dir, @files) = @_;
257 0 0       0 die "No dir\n" unless $dir;
258 0 0       0 die "No files\n" unless @files;
259 0         0 my ($token, $domain) = (read_token(), read_domain());
260 0         0 my $tx_p = api_request 'post_p', $domain, $token,
261             [ 'execute', 'Fileman', 'upload_files' ],
262             undef,
263             form => make_upload_form($dir, @files),
264             ;
265 0         0 $tx_p->then(\&_error_or_json)->then(\&_uapi_error_or_json);
266             }
267              
268             sub _api2_error_or_json {
269 0     0   0 my $json = $_[0];
270 0         0 my $result = $json->{cpanelresult};
271 0 0 0     0 if (!$result or !$result->{event}{result} or $result->{error}) {
      0        
272             die join '', "Failed:\n",
273             map "$_\n",
274             ($result->{error} ? $result->{error} : ()),
275 0 0       0 (map "$_->{src}: $_->{err}", grep !$_->{result}, @{$result->{data} || []}),
  0 0       0  
276             ;
277             }
278 0         0 $json;
279             }
280              
281             sub api2_p {
282 0     0 0 0 my ($module, $function, @args) = @_;
283 0 0       0 die "No module\n" unless $module;
284 0 0       0 die "No function\n" unless $function;
285 0         0 my ($token, $domain) = (read_token(), read_domain());
286 0 0       0 my $args_hash = ref($args[0]) eq 'HASH'
    0          
287             ? $args[0]
288             : { map split('=', $_, 2), @args }
289             if @args;
290             my $tx_p = api_request 'post_p', $domain, $token,
291             [ qw(json-api cpanel) ],
292             {
293             cpanel_jsonapi_module => $module,
294             cpanel_jsonapi_func => $function,
295             cpanel_jsonapi_apiversion => 2,
296 0 0       0 %{ $args_hash || {} },
  0         0  
297             };
298 0         0 $tx_p->then(\&_error_or_json)->then(\&_api2_error_or_json);
299             }
300              
301             sub dir_walk_p {
302 2     2 1 149 my ($from_dir, $to_dir, $from_map, $to_map, $to_dir_created) = @_;
303 2         3 my $to_dir_create_p;
304 2 100       6 if ($to_dir_created) {
305 1         4 $to_dir_create_p = Mojo::Promise->resolve(1);
306             } else {
307 1         2 my $from_dir_perms;
308             $to_dir_create_p = $to_map->{ls}->($to_dir)->catch(sub {
309             # only create if ls fails
310 1     1   439 $to_map->{mkdir}->($to_dir)
311             })->then(sub {
312 1     1   419 $from_map->{ls}->(path($from_dir)->dirname)
313             })->then(sub {
314 1     1   535 my ($dirs, $files) = @_;
315 1   50     5 $from_dir_perms = $dirs->{path($from_dir)->basename}[0] || '0755';
316             })->then(sub {
317 1     1   156 $to_map->{chmod}->($to_dir, $from_dir_perms)
318 1         8 });
319             }
320             $to_dir_create_p->then(sub {
321 2     2   524 $from_map->{ls}->($from_dir)
322             })->then(sub {
323 2     2   1089 my ($dirs, $files) = @_;
324             my @dir_create_p = map {
325 2         9 my $this_dir = $_;
  1         3  
326             $to_map->{mkdir}->("$to_dir/$this_dir")
327 1         299 ->then(sub { $to_map->{chmod}->("$to_dir/$this_dir", $dirs->{$this_dir}[0]) })
328 1         540 ->then(sub { dir_walk_p("$from_dir/$this_dir", "$to_dir/$this_dir", $from_map, $to_map, 1) })
329 1         5 } sort keys %$dirs;
330             my @file_create_p = map {
331 2         222 my $this_file = $_;
  3         204  
332             $from_map->{read}->($from_dir, $this_file)
333 3         597 ->then(sub { $to_map->{write}->($to_dir, $this_file, $_[0]) })
334 3         789 ->then(sub { $to_map->{chmod}->("$to_dir/$this_file", $files->{$this_file}[0]) })
335 3         13 } sort keys %$files;
336 2         418 Mojo::Promise->all(@dir_create_p, @file_create_p);
337 2         622 });
338             }
339              
340             sub mirror_p {
341 0     0 0   my ($from_dir, $to_dir, $from_map, $to_map) = @_;
342 0 0         die "No from_dir\n" unless $from_dir;
343 0 0         die "No to_dir\n" unless $to_dir;
344 0 0         die "No from_map\n" unless $from_map;
345 0 0         die "No to_map\n" unless $to_map;
346 0 0         die "Invalid from_map\n" unless $from_map = $MAP2HASH{$from_map};
347 0 0         die "Invalid to_map\n" unless $to_map = $MAP2HASH{$to_map};
348 0           dir_walk_p $from_dir, $to_dir, $from_map, $to_map;
349             }
350              
351             sub localfs_ls {
352 0     0 0   my ($dir) = @_;
353 0           my $dir_path = path($dir);
354             my %files = map {
355 0           ($_->basename => [ sprintf("%04o", $_->lstat->mode & 07777), $_->lstat->mtime ])
  0            
356             } $dir_path->list({hidden => 1})->each;
357             my %dirs = map {
358 0           ($_->basename => [ sprintf("%04o", $_->lstat->mode & 07777), $_->lstat->mtime ])
359 0     0     } $dir_path->list({dir => 1, hidden => 1})->grep(sub { !$files{$_->basename} })->each;
  0            
360 0           Mojo::Promise->resolve(\%dirs, \%files);
361             }
362              
363             sub localfs_mkdir {
364 0     0 0   my ($dir) = @_;
365 0           my $dir_path = path($dir);
366 0           $dir_path->make_path;
367 0           Mojo::Promise->resolve(1);
368             }
369              
370             sub localfs_read {
371 0     0 0   my ($dir, $file) = @_;
372 0           my $path = path($dir)->child($file);
373 0           Mojo::Promise->resolve($path->slurp);
374             }
375              
376             sub localfs_write {
377 0     0 0   my ($dir, $file, $content) = @_;
378 0           my $path = path($dir)->child($file);
379 0           $path->spurt($content);
380 0           Mojo::Promise->resolve(1);
381             }
382              
383             sub localfs_chmod {
384 0     0 0   my ($path, $perms) = @_;
385 0           $path = path($path);
386 0           $path->chmod(oct $perms);
387 0           Mojo::Promise->resolve(1);
388             }
389              
390             sub cpanel_ls {
391 0     0 0   my ($dir) = @_;
392             uapi_p(qw(Fileman list_files), { dir => $dir })->then(sub {
393 0     0     my (%dirs, %files);
394             ($_->{type} eq 'dir' ? \%dirs : \%files)->{$_->{file}} =
395             [ $_->{nicemode}, $_->{mtime} ]
396 0 0         for @{ $_[0]->{data} };
  0            
397 0           (\%dirs, \%files);
398 0           });
399             }
400              
401             sub cpanel_read {
402 0     0 0   my ($dir, $file) = @_;
403 0           download_p "$dir/$file";
404             }
405              
406             sub cpanel_mkdir {
407 0     0 0   my ($dir) = @_;
408 0           $dir = path $dir;
409 0           api2_p qw(Fileman mkdir), { path => $dir->dirname, name => $dir->basename };
410             }
411              
412             sub cpanel_write {
413 0     0 0   my ($dir, $file, $content) = @_;
414 0           api2_p qw(Fileman savefile), {
415             dir => $dir, filename => $file, content => $content,
416             };
417             }
418              
419             sub cpanel_chmod {
420 0     0 0   my ($path, $perms) = @_;
421 0           api2_p qw(Fileman fileop), {
422             op => 'chmod', metadata => $perms, sourcefiles => $path,
423             };
424             }
425              
426             1;