diff --git a/Makefile b/Makefile index cbe0645..a56e3cb 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,12 @@ deb ${DEB}: install: install -d -m 755 ${PERL5DIR}/PVE/APIServer install -m 0644 PVE/APIServer/AnyEvent.pm ${PERL5DIR}/PVE/APIServer + install -m 0644 PVE/APIServer/Formatter.pm ${PERL5DIR}/PVE/APIServer + install -d -m 755 ${PERL5DIR}/PVE/APIServer/Formatter + install -m 0644 PVE/APIServer/Formatter/Standard.pm ${PERL5DIR}/PVE/APIServer/Formatter + install -m 0644 PVE/APIServer/Formatter/Bootstrap.pm ${PERL5DIR}/PVE/APIServer/Formatter + install -m 0644 PVE/APIServer/Formatter/HTML.pm ${PERL5DIR}/PVE/APIServer/Formatter + .PHONY: upload upload: ${DEB} diff --git a/PVE/APIServer/AnyEvent.pm b/PVE/APIServer/AnyEvent.pm index 2ac3e39..d818e61 100755 --- a/PVE/APIServer/AnyEvent.pm +++ b/PVE/APIServer/AnyEvent.pm @@ -24,6 +24,7 @@ use Compress::Zlib; use PVE::SafeSyslog; use PVE::INotify; use PVE::Tools; +use PVE::APIServer::Formatter; use Net::IP; use URI; @@ -39,7 +40,6 @@ my $limit_max_headers = 30; my $limit_max_header_size = 8*1024; my $limit_max_post = 16*1024; - my $known_methods = { GET => 1, POST => 1, @@ -56,57 +56,6 @@ my $split_abs_uri = sub { return wantarray ? ($rel_uri, $format) : $rel_uri; }; -# generic formatter support - -my $formatter_hash = {}; - -sub register_formatter { - my ($format, $func) = @_; - - die "formatter '$format' already defined" if $formatter_hash->{$format}; - - $formatter_hash->{$format} = { - func => $func, - }; -} - -sub get_formatter { - my ($format) = @_; - - return undef if !$format; - - my $info = $formatter_hash->{$format}; - return undef if !$info; - - return $info->{func}; -} - -my $login_formatter_hash = {}; - -sub register_login_formatter { - my ($format, $func) = @_; - - die "login formatter '$format' already defined" if $login_formatter_hash->{$format}; - - $login_formatter_hash->{$format} = { - func => $func, - }; -} - -sub get_login_formatter { - my ($format) = @_; - - return undef if !$format; - - my $info = $login_formatter_hash->{$format}; - return undef if !$info; - - return $info->{func}; -} - - -# server implementation - sub log_request { my ($self, $reqstate) = @_; @@ -143,28 +92,6 @@ sub log_aborted_request { $self->log_request($reqstate); } -sub extract_auth_cookie { - my ($cookie, $cookie_name) = @_; - - return undef if !$cookie; - - my $ticket = ($cookie =~ /(?:^|\s)\Q$cookie_name\E=([^;]*)/)[0]; - - if ($ticket && $ticket =~ m/^PVE%3A/) { - $ticket = uri_unescape($ticket); - } - - return $ticket; -} - -sub create_auth_cookie { - my ($ticket, $cookie_name) = @_; - - my $encticket = uri_escape($ticket); - - return "${cookie_name}=$encticket; path=/; secure;"; -} - sub cleanup_reqstate { my ($reqstate) = @_; @@ -606,7 +533,7 @@ sub proxy_request { PVEClientIP => $clientip, }; - $headers->{'cookie'} = create_auth_cookie($ticket, $self->{cookie_name}) if $ticket; + $headers->{'cookie'} = PVE::APIServer::Formatter::create_auth_cookie($ticket, $self->{cookie_name}) if $ticket; $headers->{'CSRFPreventionToken'} = $token if $token; $headers->{'Accept-Encoding'} = 'gzip' if $reqstate->{accept_gzip}; @@ -730,7 +657,7 @@ sub handle_api2_request { my ($rel_uri, $format) = &$split_abs_uri($path, $self->{base_uri}); - my $formatter = get_formatter($format); + my $formatter = PVE::APIServer::Formatter::get_formatter($format); if (!defined($formatter)) { $self->error($reqstate, HTTP_NOT_IMPLEMENTED, "no such uri $rel_uri, $format"); @@ -1256,7 +1183,7 @@ sub unshift_read_header { } elsif ($path =~ m/^\Q$base_uri\E/) { my $token = $r->header('CSRFPreventionToken'); my $cookie = $r->header('Cookie'); - my $ticket = extract_auth_cookie($cookie, $self->{cookie_name}); + my $ticket = PVE::APIServer::Formatter::extract_auth_cookie($cookie, $self->{cookie_name}); my ($rel_uri, $format) = &$split_abs_uri($path, $self->{base_uri}); if (!$format) { @@ -1276,7 +1203,7 @@ sub unshift_read_header { if (my $err = $@) { # always delay unauthorized calls by 3 seconds my $delay = 3; - if (my $formatter = get_login_formatter($format)) { + if (my $formatter = PVE::APIServer::Formatter::get_login_formatter($format)) { my ($raw, $ct, $nocomp) = &$formatter($path, $auth); my $resp; if (ref($raw) && (ref($raw) eq 'HTTP::Response')) { diff --git a/PVE/APIServer/Formatter.pm b/PVE/APIServer/Formatter.pm new file mode 100644 index 0000000..29f1898 --- /dev/null +++ b/PVE/APIServer/Formatter.pm @@ -0,0 +1,79 @@ +package PVE::APIServer::Formatter; + +use strict; +use warnings; + +# generic formatter support +# PVE::APIServer::Formatter::* classes should register themselves here + +my $formatter_hash = {}; + +sub register_formatter { + my ($format, $func) = @_; + + die "formatter '$format' already defined" if $formatter_hash->{$format}; + + $formatter_hash->{$format} = { + func => $func, + }; +} + +sub get_formatter { + my ($format) = @_; + + return undef if !$format; + + my $info = $formatter_hash->{$format}; + return undef if !$info; + + return $info->{func}; +} + +my $login_formatter_hash = {}; + +sub register_login_formatter { + my ($format, $func) = @_; + + die "login formatter '$format' already defined" if $login_formatter_hash->{$format}; + + $login_formatter_hash->{$format} = { + func => $func, + }; +} + +sub get_login_formatter { + my ($format) = @_; + + return undef if !$format; + + my $info = $login_formatter_hash->{$format}; + return undef if !$info; + + return $info->{func}; +} + +# some helper functions + +sub extract_auth_cookie { + my ($cookie, $cookie_name) = @_; + + return undef if !$cookie; + + my $ticket = ($cookie =~ /(?:^|\s)\Q$cookie_name\E=([^;]*)/)[0]; + + if ($ticket && $ticket =~ m/^PVE%3A/) { + $ticket = uri_unescape($ticket); + } + + return $ticket; +} + +sub create_auth_cookie { + my ($ticket, $cookie_name) = @_; + + my $encticket = uri_escape($ticket); + + return "${cookie_name}=$encticket; path=/; secure;"; +} + +1; diff --git a/PVE/APIServer/Formatter/Bootstrap.pm b/PVE/APIServer/Formatter/Bootstrap.pm new file mode 100644 index 0000000..be9d3d8 --- /dev/null +++ b/PVE/APIServer/Formatter/Bootstrap.pm @@ -0,0 +1,238 @@ +package PVE::APIServer::Formatter::Bootstrap; + +use strict; +use warnings; +use URI::Escape; +use HTML::Entities; +use JSON; + +use PVE::AccessControl; # to generate CSRF token + +# Helpers to generate simple html pages using Bootstrap markup. + +my $jssrc = <<_EOJS; +PVE = { + delete_auth_cookie: function() { + document.cookie = "PVEAuthCookie=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/; secure;"; + }, + open_vm_console: function(node, vmid) { + console.log("open vm " + vmid + " on node " + node); + + var downloadWithName = function(uri, name) { + var link = jQuery('#pve_console_anchor'); + link.attr("href", uri); + + // Note: we need to tell android the correct file name extension + // but we do not set 'download' tag for other environments, because + // It can have strange side effects (additional user prompt on firefox) + var andriod = navigator.userAgent.match(/Android/i) ? true : false; + if (andriod) { + link.attr("download", name); + } + + if (document.createEvent) { + var evt = document.createEvent("MouseEvents"); + evt.initMouseEvent('click', true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null); + link.get(0).dispatchEvent(evt); + } else { + link.get(0).fireEvent('onclick'); + } + }; + + jQuery.ajax("/api2/json/console", { + data: { vmid: vmid, node: node }, + headers: { CSRFPreventionToken: PVE.CSRFPreventionToken }, + dataType: 'json', + type: 'POST', + error: function(jqXHR, textStatus, errorThrown) { + // fixme: howto view JS errors ? + console.log("ERROR " + textStatus + ": " + errorThrown); + }, + success: function(data) { + var raw = "[virt-viewer]\\n"; + jQuery.each(data.data, function(k, v) { + raw += k + "=" + v + "\\n"; + }); + var url = 'data:application/x-virt-viewer;charset=UTF-8,' + + encodeURIComponent(raw); + + downloadWithName(url, "pve-spice.vv"); + } + }); + } +}; +_EOJS + +sub new { + my ($class, $res, $url) = @_; + + my $self = bless { + url => $url, + js => '', + }; + + if (my $username = $res->{auth}->{userid}) { + $self->{csrftoken} = PVE::AccessControl::assemble_csrf_prevention_token($username); + } + + return $self; +} + +sub body { + my ($self, $html) = @_; + + my $jssetup = ''; + + if ($self->{csrftoken}) { + $jssetup .= "PVE.CSRFPreventionToken = '$self->{csrftoken}';\n"; + } + + return <<_EOD; + + + + + + + Proxmox VE API + + + + + + + + + + + + + + + + + + + + + $html + + + +_EOD +} + +my $comp_id_counter = 0; + +sub el { + my ($self, %param) = @_; + + $param{tag} = 'div' if !$param{tag}; + + my $id; + + my $html = "<$param{tag}"; + + if (wantarray) { + $comp_id_counter++; + $id = "pveid$comp_id_counter"; + $html .= " id=$id"; + } + + my $skip = { + tag => 1, + cn => 1, + html => 1, + text => 1, + }; + + my $boolattr = { + required => 1, + autofocus => 1, + }; + + my $noescape = { + placeholder => 1, + }; + + foreach my $attr (keys %param) { + next if $skip->{$attr}; + my $v = $noescape->{$attr} ? $param{$attr} : uri_escape_utf8($param{$attr},"[^\/\ A-Za-z0-9\-\._~]"); + next if !defined($v); + if ($boolattr->{$attr}) { + $html .= " $attr" if $v; + } else { + $html .= " $attr=\"$v\""; + } + } + + $html .= ">"; + + + if (my $cn = $param{cn}) { + if(ref($cn) eq 'ARRAY'){ + foreach my $rec (@$cn) { + $html .= $self->el(%$rec); + } + } else { + $html .= $self->el(%$cn); + } + } elsif ($param{html}) { + $html .= $param{html}; + } elsif ($param{text}) { + $html .= encode_entities($param{text}); + } + + $html .= ""; + + return wantarray ? ($html, $id) : $html; +} + +sub alert { + my ($self, %param) = @_; + + return $self->el(class => "alert alert-danger", %param); +} + +sub add_js { + my ($self, $js) = @_; + + $self->{js} .= $js . "\n"; +} + +my $format_event_callback = sub { + my ($info) = @_; + + my $pstr = encode_json($info->{param}); + return "function(e){$info->{fn}.apply(e, $pstr);}"; +}; + +sub button { + my ($self, %param) = @_; + + $param{tag} = 'button'; + $param{class} = "btn btn-default btn-xs"; + + if (my $click = delete $param{click}) { + my ($html, $id) = $self->el(%param); + my $cb = &$format_event_callback($click); + $self->add_js("jQuery('#$id').on('click', $cb);"); + return $html; + } else { + return $self->el(%param); + } +} + +1; diff --git a/PVE/APIServer/Formatter/HTML.pm b/PVE/APIServer/Formatter/HTML.pm new file mode 100644 index 0000000..a27c88a --- /dev/null +++ b/PVE/APIServer/Formatter/HTML.pm @@ -0,0 +1,288 @@ +package PVE::APIServer::Formatter::HTML; + +use strict; +use warnings; + +use PVE::APIServer::Formatter; +use HTTP::Status; +use JSON; +use HTML::Entities; +use PVE::JSONSchema; +use PVE::APIServer::Formatter::Bootstrap; +use PVE::APIServer::Formatter::Standard; + +my $portal_format = 'html'; +my $portal_ct = 'text/html;charset=UTF-8'; + +my $baseurl = "/api2/$portal_format"; +my $login_url = "$baseurl/access/ticket"; + +sub render_page { + my ($doc, $html) = @_; + + my $items = []; + + push @$items, { + tag => 'li', + cn => { + tag => 'a', + href => $login_url, + onClick => "PVE.delete_auth_cookie();", + text => "Logout", + }}; + + + my $title = "Proxmox VE"; + + my $nav = $doc->el( + class => "navbar navbar-inverse navbar-fixed-top", + role => "navigation", cn => { + class => "container", cn => [ + { + class => "navbar-header", cn => [ + { + tag => 'button', + type => 'button', + class => "navbar-toggle", + 'data-toggle' => "collapse", + 'data-target' => ".navbar-collapse", + cn => [ + { tag => 'span', class => 'sr-only', text => "Toggle navigation" }, + { tag => 'span', class => 'icon-bar' }, + { tag => 'span', class => 'icon-bar' }, + { tag => 'span', class => 'icon-bar' }, + ], + }, + { + tag => 'a', + class => "navbar-brand", + href => $baseurl, + text => $title, + }, + ], + }, + { + class => "collapse navbar-collapse", + cn => { + tag => 'ul', + class => "nav navbar-nav", + cn => $items, + }, + }, + ], + }); + + $items = []; + my @pcomp = split('/', $doc->{url}); + shift @pcomp; # empty + shift @pcomp; # api2 + shift @pcomp; # $format + + my $href = $baseurl; + push @$items, { tag => 'li', cn => { + tag => 'a', + href => $href, + text => 'Home'}}; + + foreach my $comp (@pcomp) { + $href .= "/$comp"; + push @$items, { tag => 'li', cn => { + tag => 'a', + href => $href, + text => $comp}}; + } + + my $breadcrumbs = $doc->el(tag => 'ol', class => 'breadcrumb container', cn => $items); + + return $doc->body($nav . $breadcrumbs . $html); +} + +my $login_form = sub { + my ($doc, $param, $errmsg) = @_; + + $param = {} if !$param; + + my $username = $param->{username} || ''; + my $password = $param->{password} || ''; + + my $items = [ + { + tag => 'label', + text => "Please sign in", + }, + { + tag => 'input', + type => 'text', + class => 'form-control', + name => 'username', + value => $username, + placeholder => "Enter user name", + required => 1, + autofocus => 1, + }, + { + tag => 'input', + type => 'password', + class => 'form-control', + name => 'password', + value => $password, + placeholder => 'Password', + required => 1, + }, + ]; + + my $html = ''; + + $html .= $doc->alert(text => $errmsg) if ($errmsg); + + $html .= $doc->el( + class => 'container', + cn => { + tag => 'form', + role => 'form', + method => 'POST', + action => $login_url, + cn => [ + { + class => 'form-group', + cn => $items, + }, + { + tag => 'button', + type => 'submit', + class => 'btn btn-lg btn-primary btn-block', + text => "Sign in", + }, + ], + }); + + return $html; +}; + +PVE::APIServer::Formatter::register_login_formatter($portal_format, sub { + my ($path, $auth) = @_; + + my $headers = HTTP::Headers->new(Location => $login_url); + return HTTP::Response->new(301, "Moved", $headers); +}); + +PVE::APIServer::Formatter::register_formatter($portal_format, sub { + my ($res, $data, $param, $path, $auth) = @_; + + # fixme: clumsy! + PVE::API2::Formatter::Standard::prepare_response_data($portal_format, $res); + $data = $res->{data}; + + my $html = ''; + my $doc = PVE::API2::Formatter::Bootstrap->new($res, $path); + + if (!HTTP::Status::is_success($res->{status})) { + $html .= $doc->alert(text => "Error $res->{status}: $res->{message}"); + } + + my $info = $res->{info}; + + $html .= $doc->el(tag => 'h3', text => 'Description'); + $html .= $doc->el(tag => 'p', text => $info->{description}); + + my $lnk = PVE::JSONSchema::method_get_child_link($info); + + if ($lnk && $data && $data->{data} && HTTP::Status::is_success($res->{status})) { + + my $href = $lnk->{href}; + if ($href =~ m/^\{(\S+)\}$/) { + + my $items = []; + + my $prop = $1; + $path =~ s/\/+$//; # remove trailing slash + + foreach my $elem (sort {$a->{$prop} cmp $b->{$prop}} @{$data->{data}}) { + next if !ref($elem); + + if (defined(my $value = $elem->{$prop})) { + my $tv = to_json($elem, {pretty => 1, allow_nonref => 1, canonical => 1}); + + push @$items, { + tag => 'a', + class => 'list-group-item', + href => "$path/$value", + cn => [ + { + tag => 'h4', + class => 'list-group-item-heading', + text => $value, + }, + { + tag => 'pre', + class => 'list-group-item', + text => $tv, + }, + ], + }; + } + } + + $html .= $doc->el(class => 'list-group', cn => $items); + + } else { + + my $json = to_json($data, {allow_nonref => 1, pretty => 1}); + $html .= $doc->el(tag => 'pre', text => $json); + } + + } else { + + my $json = to_json($data, {allow_nonref => 1, pretty => 1}); + $html .= $doc->el(tag => 'pre', text => $json); + } + + $html = $doc->el(class => 'container', html => $html); + + my $raw = render_page($doc, $html); + return ($raw, $portal_ct); +}); + +PVE::RESTHandler->register_page_formatter( + 'format' => $portal_format, + method => 'GET', + path => "/access/ticket", + code => sub { + my ($res, $data, $param, $path, $auth) = @_; + + my $doc = PVE::API2::Formatter::Bootstrap->new($res, $path); + + my $html = &$login_form($doc); + + my $raw = render_page($doc, $html); + return ($raw, $portal_ct); + }); + +PVE::RESTHandler->register_page_formatter( + 'format' => $portal_format, + method => 'POST', + path => "/access/ticket", + code => sub { + my ($res, $data, $param, $path, $auth) = @_; + + if (HTTP::Status::is_success($res->{status})) { + my $cookie = PVE::APIServer::Formatter::create_auth_cookie( + $data->{ticket}, $auth->{cookie_name}); + + my $headers = HTTP::Headers->new(Location => $baseurl, + 'Set-Cookie' => $cookie); + return HTTP::Response->new(301, "Moved", $headers); + } + + # Note: HTTP server redirects to 'GET /access/ticket', so below + # output is not really visible. + + my $doc = PVE::APIServer::Formatter::Bootstrap->new($res, $path); + + my $html = &$login_form($doc); + + my $raw = render_page($doc, $html); + return ($raw, $portal_ct); + }); + +1; diff --git a/PVE/APIServer/Formatter/Standard.pm b/PVE/APIServer/Formatter/Standard.pm new file mode 100644 index 0000000..cf5aac5 --- /dev/null +++ b/PVE/APIServer/Formatter/Standard.pm @@ -0,0 +1,141 @@ +package PVE::APIServer::Formatter::Standard; + +use strict; +use warnings; + +use PVE::APIServer::Formatter; +use HTTP::Status; +use JSON; +use HTML::Entities; +use PVE::JSONSchema; + +# register result formatters + +sub prepare_response_data { + my ($format, $res) = @_; + + my $success = 1; + my $new = { + data => $res->{data}, + }; + if (scalar(keys %{$res->{errors}})) { + $success = 0; + $new->{errors} = $res->{errors}; + } + + if ($format eq 'extjs' || $format eq 'htmljs') { + # HACK: extjs wants 'success' property instead of useful HTTP status codes + if (HTTP::Status::is_error($res->{status})) { + $success = 0; + $new->{message} = $res->{message} || status_message($res->{status}); + $new->{status} = $res->{status} || 200; + $res->{message} = undef; + $res->{status} = 200; + } + $new->{success} = $success; + } + + if ($success && $res->{total}) { + $new->{total} = $res->{total}; + } + + if ($success && $res->{changes}) { + $new->{changes} = $res->{changes}; + } + + $res->{data} = $new; +} + +PVE::APIServer::Formatter::register_formatter('json', sub { + my ($res, $data, $param, $path, $auth) = @_; + + my $nocomp = 0; + + my $ct = 'application/json;charset=UTF-8'; + + prepare_response_data('json', $res); + + my $raw = to_json($res->{data}, {utf8 => 1, allow_nonref => 1}); + + return ($raw, $ct, $nocomp); +}); + + +PVE::APIServer::Formatter::register_formatter('extjs', sub { + my ($res, $data, $param, $path, $auth) = @_; + + my $nocomp = 0; + + my $ct = 'application/json;charset=UTF-8'; + + prepare_response_data('extjs', $res); + + my $raw = to_json($res->{data}, {utf8 => 1, allow_nonref => 1}); + + return ($raw, $ct, $nocomp); +}); + +PVE::APIServer::Formatter::register_formatter('htmljs', sub { + my ($res, $data, $param, $path, $auth) = @_; + + my $nocomp = 0; + + # we use this for extjs file upload forms + + my $ct = 'text/html;charset=UTF-8'; + + prepare_response_data('htmljs', $res); + + my $raw = encode_entities(to_json($res->{data}, {allow_nonref => 1})); + + return ($raw, $ct, $nocomp); +}); + + +PVE::APIServer::Formatter::register_formatter('spiceconfig', sub { + my ($res, $data, $param, $path, $auth) = @_; + + my $nocomp = 0; + + my $ct = 'application/x-virt-viewer;charset=UTF-8'; + + prepare_response_data('spiceconfig', $res); + + $data = $res->{data}; + + my $raw; + + if ($data && ref($data) && ref($data->{data})) { + $raw = "[virt-viewer]\n"; + while (my ($key, $value) = each %{$data->{data}}) { + $raw .= "$key=$value\n" if defined($value); + } + } + + return ($raw, $ct, $nocomp); +}); + +PVE::APIServer::Formatter::register_formatter('png', sub { + my ($res, $data, $param, $path, $auth) = @_; + + my $nocomp = 1; + + my $ct = 'image/png'; + + prepare_response_data('png', $res); + + $data = $res->{data}; + + # fixme: better to revove that whole png thing ? + + my $filename; + my $raw = ''; + + if ($data && ref($data) && ref($data->{data}) && + $data->{data}->{filename} && defined($data->{data}->{image})) { + $filename = $data->{data}->{filename}; + $raw = $data->{data}->{image}; + } + + return ($raw, $ct, $nocomp); +}); diff --git a/debian/control b/debian/control index 5460c1e..66c66eb 100644 --- a/debian/control +++ b/debian/control @@ -8,6 +8,6 @@ Homepage: http://www.proxmox.com Package: libpve-http-server-perl Architecture: all -Depends: ${misc:Depends}, libnet-ip-perl, libio-socket-ssl-perl, libjson-perl, libcrypt-ssleay-perl, libhttp-date-perl, libhttp-message-perl, liburi-perl, libanyevent-perl, libanyevent-http-perl, libpve-common-perl +Depends: ${misc:Depends}, libnet-ip-perl, libio-socket-ssl-perl, libjson-perl, libcrypt-ssleay-perl, libhttp-date-perl, libhttp-message-perl, liburi-perl, libhtml-parser-perl, libanyevent-perl, libanyevent-http-perl, libpve-common-perl Description: Proxmox Asynchrounous HTTP Server Implementation This is used to implement the PVE REST API. \ No newline at end of file