summaryrefslogtreecommitdiffstats
path: root/Bugzilla/API/1_0/Server.pm
blob: a443cd51435549ff8d70ff1b426c1beb7cbcfaba (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
#
# This Source Code Form is "Incompatible With Secondary Licenses", as
# defined by the Mozilla Public License, v. 2.0.

package Bugzilla::API::1_0::Server;

use 5.14.0;
use strict;
use warnings;

use Bugzilla::API::1_0::Constants qw(API_AUTH_HEADERS);
use Bugzilla::API::1_0::Util qw(taint_data fix_credentials api_include_exclude);

use Bugzilla::Constants;
use Bugzilla::Error;
use Bugzilla::Hook;
use Bugzilla::Util qw(datetime_from trick_taint);

use File::Basename qw(basename);
use File::Glob qw(:glob);
use List::MoreUtils qw(none uniq);
use MIME::Base64 qw(decode_base64 encode_base64);
use Moo;
use Scalar::Util qw(blessed);

extends 'Bugzilla::API::Server';

############
# Start up #
############

has api_version   => (is => 'ro', default => '1_0',  init_arg => undef);
has api_namespace => (is => 'ro', default => 'core', init_arg => undef);

sub _build_content_type {
    # Determine how the data should be represented. We do this early so
    # errors will also be returned with the proper content type.
    # If no accept header was sent or the content types specified were not
    # matched, we default to the first type in the whitelist.
    return $_[0]->_best_content_type(
        @{ $_[0]->constants->{REST_CONTENT_TYPE_WHITELIST} });
}

##################
# Public Methods #
##################

sub handle {
    my ($self)  = @_;

   # Using current path information, decide which class/method to
    # use to serve the request. Throw error if no resource was found
    # unless we were looking for OPTIONS
    if (!$self->_find_resource) {
        if ($self->request->method eq 'OPTIONS'
            && $self->api_options)
        {
            my $response = $self->response_header($self->constants->{STATUS_OK}, "");
            my $options_string = join(', ', @{ $self->api_options });
            $response->header('Allow' => $options_string,
                              'Access-Control-Allow-Methods' => $options_string);
            return $self->print_response($response);
        }

        ThrowUserError("rest_invalid_resource",
                       { path   => $self->cgi->path_info,
                         method => $self->request->method });
    }

    my $params = $self->_retrieve_json_params;
    $self->_params_check($params);

    fix_credentials($params);

    # Fix includes/excludes for each call
    api_include_exclude($params);

    # Set callback name if exists
    $self->callback($params->{'callback'}) if $params->{'callback'};

    Bugzilla->input_params($params);

    # Let's try to authenticate before executing
    $self->handle_login;

    # Execute the handler
    my $result = $self->_handle;

    # The result needs to be a valid JSON data structure
    # and not a undefined or scalar value.
    if (!ref $result
        || blessed($result)
        || (ref $result ne 'HASH' && ref $result ne 'ARRAY'))
    {
        $result = { result => $result };
    }

    $self->response($result);
}

sub response {
    my ($self, $result) = @_;

    # Error data needs to be formatted differently
    my $status_code;
    if (my $error = $self->return_error) {
        $status_code = delete $error->{status_code};
        $error->{documentation} = REST_DOC;
        $result = $error;
    }
    else {
        $status_code = $self->success_code;
    }

    Bugzilla::Hook::process('webservice_rest_result',
        { api => $self, result => \$result });

    # ETag support
    my $etag = $self->etag;
    $self->etag($result) if !$etag;

    # If accessing through web browser, then display in readable format
    my $content;
    if ($self->content_type eq 'text/html') {
        $result = $self->json->pretty->canonical->allow_nonref->encode($result);
        my $template = Bugzilla->template;
        $template->process("rest.html.tmpl", { result => $result }, \$content)
            || ThrowTemplateError($template->error());
    }
    else {
        $content = $self->json->encode($result);
    }

    if (my $callback = $self->callback) {
        # Prepend the response with /**/ in order to protect
        # against possible encoding attacks (e.g., affecting Flash).
        $content = "/**/$callback($content)";
    }

    my $response = $self->response_header($status_code, $content);

    Bugzilla::Hook::process('webservice_rest_response',
        { api => $self, response => $response });

    $self->print_response($response);
}

sub print_response {
    my ($self, $response) = @_;

    # Access Control
    my @allowed_headers = qw(accept content-type origin x-requested-with);
    foreach my $header (keys %{ API_AUTH_HEADERS() }) {
        # We want to lowercase and replace _ with -
        my $translated_header = $header;
        $translated_header =~ tr/A-Z_/a-z\-/;
        push(@allowed_headers, $translated_header);
    }
    $response->header("Access-Control-Allow-Origin", "*");
    $response->header("Access-Control-Allow-Headers", join(', ', @allowed_headers));

    # Use $cgi->header properly instead of just printing text directly.
    # This fixes various problems, including sending Bugzilla's cookies
    # properly.
    my $headers = $response->headers;
    my @header_args;
    foreach my $name ($headers->header_field_names) {
        my @values = $headers->header($name);
        $name =~ s/-/_/g;
        foreach my $value (@values) {
            push(@header_args, "-$name", $value);
        }
    }

    # ETag support
    my $etag = $self->etag;
    if ($etag && $self->cgi->check_etag($etag)) {
        push(@header_args, "-ETag", $etag);
        print $self->cgi->header(-status => '304 Not Modified', @header_args);
    }
    else {
        push(@header_args, "-ETag", $etag) if $etag;
        print $self->cgi->header(-status => $response->code, @header_args);
        print $response->content;
    }
}

sub handle_login {
    my $self = shift;
    my $controller = $self->controller;
    my $method     = $self->method_name;

    return if ($controller->login_exempt($method)
               and !defined Bugzilla->input_params->{Bugzilla_login});

    Bugzilla->login();

    Bugzilla::Hook::process('webservice_before_call',
                            { rpc => $self, controller => $controller });
}

###################
# Private Methods #
###################

sub _handle {
    my ($self) = shift;
    my $method     = $self->method_name;
    my $controller = $self->controller;
    my $params     = Bugzilla->input_params;
    my $cache      = Bugzilla->request_cache;

    unless ($controller->can($method)) {
        return $self->return_error(302, "No such a method : '$method'.");
    }

    # Let Bugzilla::Error know we are inside an eval() for exceptions
    $cache->{in_eval} = 1;
    my $result = eval { $controller->$method($params) };
    $cache->{in_eval} = 0;

    return $self->return_error if $self->return_error;

    if ($@) {
        return $self->return_error(500, "Procedure error: $@");
    }

    # Set the ETag if not already set in the webservice methods.
    my $etag = $self->etag;
    if (!$etag && ref $result) {
        $self->etag($result);
    }

    return $result;
}

sub _params_check {
    my ($self, $params) = @_;
    my $method     = $self->method_name;
    my $controller = $self->controller;

    taint_data($params);

    # Now, convert dateTime fields on input.
    my @date_fields = @{ $controller->DATE_FIELDS->{$method} || [] };
    foreach my $field (@date_fields) {
        if (defined $params->{$field}) {
            my $value = $params->{$field};
            if (ref $value eq 'ARRAY') {
                $params->{$field} =
                    [ map { $self->datetime_format_inbound($_) } @$value ];
            }
            else {
                $params->{$field} = $self->datetime_format_inbound($value);
            }
        }
    }
    my @base64_fields = @{ $controller->BASE64_FIELDS->{$method} || [] };
    foreach my $field (@base64_fields) {
        if (defined $params->{$field}) {
            $params->{$field} = decode_base64($params->{$field});
        }
    }

    if ($self->request->method eq 'POST'
        || $self->request->method eq 'PUT') {
        # CSRF is possible via XMLHttpRequest when the Content-Type header
        # is not application/json (for example: text/plain or
        # application/x-www-form-urlencoded).
        # application/json is the single official MIME type, per RFC 4627.
        my $content_type = $self->cgi->content_type;
        # The charset can be appended to the content type, so we use a regexp.
        if ($content_type !~ m{^application/json(-rpc)?(;.*)?$}i) {
            ThrowUserError('json_rpc_illegal_content_type',
                            { content_type => $content_type });
        }
    }
    else {
        # When being called using GET, we don't allow calling
        # methods that can change data. This protects us against cross-site
        # request forgeries.
        if (!grep($_ eq $method, $controller->READ_ONLY)) {
            ThrowUserError('json_rpc_post_only',
                           { method => $self->method_name });
        }
    }

    # Only allowed methods to be used from our whitelist
    if (none { $_ eq $method} $controller->PUBLIC_METHODS) {
        ThrowCodeError('unknown_method', { method => $self->method_name });
    }
}

sub _retrieve_json_params {
    my $self = shift;

    # Make a copy of the current input_params rather than edit directly
    my $params = {};
    %{$params} = %{ Bugzilla->input_params };

    # First add any parameters we were able to pull out of the path
    # based on the resource regexp and combine with the normal URL
    # parameters.
    if (my $api_params = $self->api_params) {
        foreach my $param (keys %$api_params) {
            # If the param does not already exist or if the
            # rest param is a single value, add it to the
            # global params.
            if (!exists $params->{$param} || !ref $api_params->{$param}) {
                $params->{$param} = $api_params->{$param};
            }
            # If param is a list then add any extra values to the list
            elsif (ref $api_params->{$param}) {
                my @extra_values = ref $params->{$param}
                                   ? @{ $params->{$param} }
                                   : ($params->{$param});
                $params->{$param}
                    = [ uniq (@{ $api_params->{$param} }, @extra_values) ];
            }
        }
    }

    # Any parameters passed in in the body of a non-GET request will override
    # any parameters pull from the url path. Otherwise non-unique keys are
    # combined.
    if ($self->request->method ne 'GET') {
        my $extra_params = {};
        # We do this manually because CGI.pm doesn't understand JSON strings.
        my $json = delete $params->{'POSTDATA'} || delete $params->{'PUTDATA'};
        if ($json) {
            eval { $extra_params = $self->json->decode($json); };
            if ($@) {
                ThrowUserError('json_rpc_invalid_params', { err_msg  => $@ });
            }
        }

        # Allow parameters in the query string if request was non-GET.
        # Note: parameters in query string body override any matching
        # parameters in the request body.
        foreach my $param ($self->cgi->url_param()) {
            $extra_params->{$param} = $self->cgi->url_param($param);
        }

        %{$params} = (%{$params}, %{$extra_params}) if %{$extra_params};
    }

    return $params;
}

sub _find_resource {
    my ($self) = @_;
    my $api_version     = $self->api_version;
    my $api_ext_version = $self->api_ext_version;
    my $api_namespace   = $self->api_namespace;
    my $api_path        = $self->api_path;
    my $request_method  = $self->request->method;
    my $resource_found  = 0;

    my $resource_modules;
    if ($api_ext_version) {
        $resource_modules = File::Spec->catdir(bz_locations()->{extensionsdir},
            $api_namespace, 'API', $api_ext_version, 'Resource', '*.pm');
    }
    else {
        $resource_modules = File::Spec->catdir(bz_locations()->{cgi_path},
            'Bugzilla','API', $api_version, 'Resource', '*.pm');
    }

    # Load in the WebService modules from the appropriate version directory
    # and then call $module->REST_RESOURCES to get the resources array ref.
    foreach my $module_file (bsd_glob($resource_modules)) {
        # Create a controller object
        trick_taint($module_file);
        my $module_basename = basename($module_file, '.pm');
        eval { require "$module_file"; } || die $@;
        my $module_class = "Bugzilla::API::${api_version}::Resource::${module_basename}";
        my $controller = $module_class->new;
        next if !$controller || !$controller->can('REST_RESOURCES');

        # The resource data for each module needs to be an array ref with an
        # even number of elements to work correctly.
        my $this_resources = $controller->REST_RESOURCES;
        next if (ref $this_resources ne 'ARRAY' || scalar @$this_resources % 2 != 0);

        while (my ($regex, $options_data) = splice(@$this_resources, 0, 2)) {
            next if ref $options_data ne 'HASH';

            if (my @matches = ($self->api_path =~ $regex)) {
                # If a specific path is accompanied by a OPTIONS request
                # method, the user is asking for a list of possible request
                # methods for a specific path.
                $self->api_options([ keys %$options_data ]);

                if ($options_data->{$request_method}) {
                    my $resource_data = $options_data->{$request_method};

                    # The method key/value can be a simple scalar method name
                    # or a anonymous subroutine so we execute it here.
                    my $method = ref $resource_data->{method} eq 'CODE'
                                 ? $resource_data->{method}->($self)
                                 : $resource_data->{method};
                    $self->method_name($method);

                    # Pull out any parameters parsed from the URL path
                    # and store them for use by the method.
                    if ($resource_data->{params}) {
                        $self->api_params($resource_data->{params}->(@matches));
                    }

                    # If a special success code is needed for this particular
                    # method, then store it for later when generating response.
                    if ($resource_data->{success_code}) {
                        $self->success_code($resource_data->{success_code});
                    }

                    # Stash away for later
                    $self->controller($controller);

                    # No need to look further
                    $resource_found = 1;
                    last;
                }
            }
        }
        last if $resource_found;
    }

    return $resource_found;
}

1;

__END__

=head1 NAME

Bugzilla::API::1_0::Server - The API 1.0 Interface to Bugzilla

=head1 DESCRIPTION

This documentation describes version 1.0 of the Bugzilla API. This
module inherits from L<Bugzilla::API::Server> and overrides specific
methods to make this version distinct from other versions of the API.
New versions of the API may make breaking changes by implementing
these methods in a different way.

=head1 SEE ALSO

L<Bugzilla::API::Server>

=head1 B<Methods in need of POD>

=over

=item api_namespace

=item api_version

=item handle

=item response

=item print_response

=item handle_login

=back