File Coverage

blib/lib/App/cpanel.pm
Criterion Covered Total %
statement 39 164 23.7
branch 2 74 2.7
condition 1 8 12.5
subroutine 14 43 32.5
pod 3 23 13.0
total 59 312 18.9


line stmt bran cond sub pod time code
1             package App::cpanel;
2              
3 1     1   59373 use Exporter 'import';
  1         9  
  1         60  
4              
5             our $VERSION = '0.006';
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         18  
117 1     1   4 use warnings;
  1         1  
  1         35  
118 1     1   478 use Mojo::URL;
  1         174893  
  1         9  
119 1     1   627 use Mojo::UserAgent;
  1         229358  
  1         9  
120 1     1   43 use Mojo::File qw(path);
  1         1  
  1         42  
121 1     1   5 use Mojo::Util qw(dumper encode);
  1         2  
  1         2733  
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 3     3 1 494 my ($from_dir, $to_dir, $from_map, $to_map) = @_;
303 3         4 my $from_dir_perms;
304             $to_map->{ls}->($to_dir)->catch(sub {
305             # only create if ls fails
306 3     3   1369 $to_map->{mkdir}->($to_dir)
307             })->then(sub {
308 3     3   1107 $from_map->{ls}->(path($from_dir)->dirname)
309             })->then(sub {
310 3     3   1129 my ($dirs, $files) = @_;
311 3   50     9 $from_dir_perms = $dirs->{path($from_dir)->basename}[0] || '0755';
312             })->then(sub {
313 3     3   568 $to_map->{chmod}->($to_dir, $from_dir_perms)
314             })->then(sub {
315 3     3   882 $from_map->{ls}->($from_dir)
316             })->then(sub {
317 3     3   763 my ($dirs, $files) = @_;
318 3         19 my @dir_create_p = map
319             dir_walk_p("$from_dir/$_", "$to_dir/$_", $from_map, $to_map),
320             sort keys %$dirs;
321             my @file_create_p = map {
322 3         337 my $this_file = $_;
  3         168  
323             $from_map->{read}->($from_dir, $this_file)
324 3         488 ->then(sub { $to_map->{write}->($to_dir, $this_file, $_[0]) })
325 3         780 ->then(sub { $to_map->{chmod}->("$to_dir/$this_file", $files->{$this_file}[0]) })
326 3         8 } sort keys %$files;
327 3 100       367 return Mojo::Promise->resolve(1) unless @dir_create_p + @file_create_p;
328 2         7 Mojo::Promise->all(@dir_create_p, @file_create_p);
329 3         11 });
330             }
331              
332             sub mirror_p {
333 0     0 0   my ($from_dir, $to_dir, $from_map, $to_map) = @_;
334 0 0         die "No from_dir\n" unless $from_dir;
335 0 0         die "No to_dir\n" unless $to_dir;
336 0 0         die "No from_map\n" unless $from_map;
337 0 0         die "No to_map\n" unless $to_map;
338 0 0         die "Invalid from_map\n" unless $from_map = $MAP2HASH{$from_map};
339 0 0         die "Invalid to_map\n" unless $to_map = $MAP2HASH{$to_map};
340 0           dir_walk_p $from_dir, $to_dir, $from_map, $to_map;
341             }
342              
343             sub localfs_ls {
344 0     0 0   my ($dir) = @_;
345 0           my $dir_path = path($dir);
346             my %files = map {
347 0           ($_->basename => [ sprintf("%04o", $_->lstat->mode & 07777), $_->lstat->mtime ])
  0            
348             } $dir_path->list({hidden => 1})->each;
349             my %dirs = map {
350 0           ($_->basename => [ sprintf("%04o", $_->lstat->mode & 07777), $_->lstat->mtime ])
351 0     0     } $dir_path->list({dir => 1, hidden => 1})->grep(sub { !$files{$_->basename} })->each;
  0            
352 0           Mojo::Promise->resolve(\%dirs, \%files);
353             }
354              
355             sub localfs_mkdir {
356 0     0 0   my ($dir) = @_;
357 0           my $dir_path = path($dir);
358 0           $dir_path->make_path;
359 0           Mojo::Promise->resolve(1);
360             }
361              
362             sub localfs_read {
363 0     0 0   my ($dir, $file) = @_;
364 0           my $path = path($dir)->child($file);
365 0           Mojo::Promise->resolve($path->slurp);
366             }
367              
368             sub localfs_write {
369 0     0 0   my ($dir, $file, $content) = @_;
370 0           my $path = path($dir)->child($file);
371 0           $path->spurt($content);
372 0           Mojo::Promise->resolve(1);
373             }
374              
375             sub localfs_chmod {
376 0     0 0   my ($path, $perms) = @_;
377 0           $path = path($path);
378 0           $path->chmod(oct $perms);
379 0           Mojo::Promise->resolve(1);
380             }
381              
382             sub cpanel_ls {
383 0     0 0   my ($dir) = @_;
384             uapi_p(qw(Fileman list_files), { dir => $dir })->then(sub {
385 0     0     my (%dirs, %files);
386             ($_->{type} eq 'dir' ? \%dirs : \%files)->{$_->{file}} =
387             [ $_->{nicemode}, $_->{mtime} ]
388 0 0         for @{ $_[0]->{data} };
  0            
389 0           (\%dirs, \%files);
390 0           });
391             }
392              
393             sub cpanel_read {
394 0     0 0   my ($dir, $file) = @_;
395 0           download_p "$dir/$file";
396             }
397              
398             sub cpanel_mkdir {
399 0     0 0   my ($dir) = @_;
400 0           $dir = path $dir;
401 0           api2_p qw(Fileman mkdir), { path => $dir->dirname, name => $dir->basename };
402             }
403              
404             sub cpanel_write {
405 0     0 0   my ($dir, $file, $content) = @_;
406 0           api2_p qw(Fileman savefile), {
407             dir => $dir, filename => $file, content => $content,
408             };
409             }
410              
411             sub cpanel_chmod {
412 0     0 0   my ($path, $perms) = @_;
413 0           api2_p qw(Fileman fileop), {
414             op => 'chmod', metadata => $perms, sourcefiles => $path,
415             };
416             }
417              
418             1;