add generic formatter framework

This commit is contained in:
Dietmar Maurer 2017-01-13 19:05:21 +01:00
parent d08808bc8a
commit 63307bebd9
7 changed files with 758 additions and 79 deletions

View File

@ -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}

View File

@ -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')) {

View File

@ -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;

View File

@ -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;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Proxmox VE API</title>
<!-- Bootstrap -->
<link href="/pve2/css/bootstrap.min.css" rel="stylesheet">
<script type="text/javascript">
$jssrc
$jssetup
</script>
<style>
body {
padding-top: 70px;
}
</style>
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
<![endif]-->
<!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.0/jquery.min.js"></script>
<!-- Include all compiled plugins (below), or include individual files as needed -->
<script src="/pve2/js/bootstrap.min.js"></script>
</head>
<body>
<a class="hidden" id="pve_console_anchor"></a>
$html
<script type="text/javascript">
$self->{js}
</script>
</body>
</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 .= "</$param{tag}>";
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;

View File

@ -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;

View File

@ -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);
});

2
debian/control vendored
View File

@ -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.