#!/usr/bin/perl # # DW::Routing # # Module to allow calling non-BML controller/views. # # Authors: # Andrea Nall # Mark Smith # # Copyright (c) 2009-2013 by Dreamwidth Studios, LLC. # # This program is free software; you may redistribute it and/or modify it under # the same terms as Perl itself. For a copy of the license, please reference # 'perldoc perlartistic' or 'perldoc perlgpl'. # package DW::Routing; use strict; use LJ::ModuleLoader; use DW::Template; use LJ::JSON; use DW::Request; use DW::Routing::CallInfo; use Carp qw/croak/; # IMPORTANT! # # If we change the internal representation here, the code in # bin/dev/lookup-routing must also be updated. # # Thank you. # our %string_choices; our %regex_choices = ( app => [], user => [], api => [] ); our %api_endpoints; # ver => { string => hash } our %api_rest_endpoints; # ver => { string => hash } our $T_TESTING_ERRORS; my $default_content_types = { atom => 'application/atom+xml; charset=utf-8', html => 'text/html; charset=utf-8', js => 'application/javascript; charset=utf-8', json => 'application/json; charset=utf-8', plain => 'text/plain; charset=utf-8', png => 'image/png', }; LJ::ModuleLoader->require_subclasses('DW::Controller') unless $DW::Routing::DONT_LOAD; # for testing =head1 NAME DW::Routing - Module to allow calling non-BML controller/views. =head1 Page Call API =head2 C<< $class->call( $r, %opts ) >> Valid options: =over =item uri - explicitly override the uri =item role - explicitly define the role =item username - define the username, implies username role =back This method should be directly returned by the caller if defined. =cut sub call { my $class = shift; my $call_opts = $class->get_call_opts(@_); return $class->call_hash($call_opts) if defined $call_opts; return undef; } =head2 C<< $class->get_call_opts( $r, %opts ) >> Valid options: =over =item uri - explicitly override the uri =item role - explicitly define the role =item username - define the username, implies username role =back Returns a call_opts hash, or undefined. =cut sub get_call_opts { my ( $class, %opts ) = @_; my $r = DW::Request->get; my $uri = $opts{uri} || $r->uri; my $format = undef; ( $uri, $format ) = ( $1, $2 ) if $uri =~ m/^(.+?)\.([a-z]+)$/; # discard format if the caller requested bml from an ancient link if ( $format && $format eq 'bml' ) { $format = undef; } # Role determination: if the URL starts with '/api/vX' then it's an API # call, and we should extract that information for our call options. if ( $uri =~ m!^/api/v(\d+)(/.+)$! ) { $opts{role} = 'api'; $opts{apiver} = $1 + 0; $format = 'json'; $uri = $2; } # add more data to the options hash, we'll need it $opts{role} ||= $opts{username} ? 'user' : 'app'; $opts{uri} = $uri; $opts{format} = $format; # we construct this object as an easy way to get options later, it gives # us accessors. my $call_opts = DW::Routing::CallInfo->new( \%opts ); # APIs are versioned, so we only want to check for endpoints that match # the version the user is requesting. if ( $call_opts->role eq 'api' ) { # return early if we weren't given an API version return unless defined( $call_opts->apiver ); # check the static endpoints for this api version first if ( exists $api_endpoints{ $call_opts->apiver } ) { my $hash = $api_endpoints{ $call_opts->apiver }->{$uri}; if ($hash) { $call_opts->init_call_opts($hash); return $call_opts; } } # if there's no static match, check the regexes my $endpoints_for_version = $api_rest_endpoints{ $call_opts->apiver }; if ($endpoints_for_version) { # check for a match for each regex in this version foreach my $regex ( keys %{$endpoints_for_version} ) { # this actually checks the regex and, if there's a match, # populates the @args with matched groups if ( ( my @args = $uri =~ $regex ) ) { my $call_def = $endpoints_for_version->{$regex}; $call_opts->init_call_opts( $call_def, \@args ); return $call_opts; } } } # if it's not found in either, just return. return; } # try the string options first as they're fast my $hash = $string_choices{ $call_opts->role . $uri }; if ( defined $hash ) { $call_opts->init_call_opts($hash); return $call_opts; } # try the regex choices next # FIXME: this should be a dynamically sorting array so the most used items float to the top # for now it doesn't matter so much but eventually when everything is in the routing table # that will have to be done my @args; foreach $hash ( @{ $regex_choices{ $call_opts->role } } ) { if ( ( @args = $uri =~ $hash->{regex} ) ) { $call_opts->init_call_opts( $hash, \@args ); return $call_opts; } } # failed to find anything so fall through return undef; } =head2 C<< $class->call_hash( $class, $call_opts ) >> Calls the raw hash. =cut sub call_hash { my ( $class, $opts ) = @_; my $r = DW::Request->get; my $hash = $opts->call_opts; return undef unless $hash && $hash->{sub}; $r->pnote( routing_opts => $opts ); return $r->call_response_handler( \&_call_hash ); } # INTERNAL METHOD: no POD # Perl Response Handler for call_hash sub _call_hash { my $r = DW::Request->get; my $opts = $r->pnote('routing_opts'); $opts->prepare_for_call; # check method my $method = uc( $r->method ); return $r->HTTP_METHOD_NOT_ALLOWED unless $opts->method_valid($method); # check for format validity return $r->NOT_FOUND unless $opts->format_valid; # if renamed with redirect in place, then do the redirect if ( $opts->role eq 'user' && ( my $orig_u = LJ::load_user( $opts->username ) ) ) { my $renamed_u = $orig_u->get_renamed_user; if ( $renamed_u && !$orig_u->equals($renamed_u) ) { my $journal_host = $renamed_u->journal_base; $journal_host =~ s!https?://!!; return $r->redirect( LJ::create_url( $r->uri, host => $journal_host, keep_args => 1 ) ); } } # apply default content type if it exists my $format = $opts->format; $r->content_type( $default_content_types->{$format} ) if $default_content_types->{$format}; # apply default cache-avoidant settings to "journal" content # (similar to the behavior of our Apache server modules) # so that proxies (e.g. Cloudflare) must revalidate the response if ( $opts->role eq 'user' && !$opts->no_cache ) { $r->header_out( "Cache-Control" => "private, proxy-revalidate" ); } # apply no-cache if needed $r->no_cache if $opts->no_cache; # try to call the handler that actually does the content creation; it will # return either a number (HTTP code), or undef # means there was an error of some sort my $ret = eval { $opts->call }; return $ret unless $@; # here down is simply error handling for whatever the handler sub above # might have died with my $msg = $@; my $err = LJ::errobj($msg) or die "LJ::errobj didn't return anything."; unless ($T_TESTING_ERRORS) { warn $msg; } # JSON error rendering if ( $format eq 'json' ) { $msg = $err->as_string; chomp $msg; my $text = $LJ::MSG_ERROR || "Sorry, there was a problem."; my $remote = LJ::get_remote(); $text = "$msg" if ( $remote && $remote->show_raw_errors ) || $LJ::IS_DEV_SERVER; $r->status(500); $r->print( to_json( { success => 0, error => $text } ) ); return $r->OK; # default error rendering } elsif ( $format eq "html" ) { $msg = $err->as_html; chomp $msg; $msg .= " \@ $LJ::SERVER_NAME" if $LJ::SERVER_NAME; $r->status(500); $r->content_type( $default_content_types->{html} ); my $text = $LJ::MSG_ERROR || "Sorry, there was a problem."; my $remote = LJ::get_remote(); $text = "[Error: $msg]" if ( $remote && $remote->show_raw_errors ) || $LJ::IS_DEV_SERVER; $opts->{no_sitescheme} = 1 if $T_TESTING_ERRORS; $ret = eval { return DW::Template->render_string( $text, $opts ); }; return $ret unless $@; my $msg2 = $@; my $err2 = LJ::errobj($msg2) or die "LJ::errobj didn't return anything."; unless ($T_TESTING_ERRORS) { warn $msg2; } if ( ( $remote && $remote->show_raw_errors ) || $LJ::IS_DEV_SERVER ) { $msg2 = $err2->as_html; $msg2 .= " \@ $LJ::SERVER_NAME" if $LJ::SERVER_NAME; $text .= "\n

Additionally, while trying to render this error page:"; $text .= "\n[Error 2: $msg2]"; } $r->status(500); $r->content_type('text/html'); $r->print($text); return $r->OK; } else { $msg = $err->as_string; chomp $msg; $msg .= " \@ $LJ::SERVER_NAME" if $LJ::SERVER_NAME; my $text = $LJ::MSG_ERROR || "Sorry, there was a problem."; my $remote = LJ::get_remote(); $text = "Error: $msg" if ( $remote && $remote->show_raw_errors ) || $LJ::IS_DEV_SERVER; $r->status(500); $r->content_type('text/plain'); $r->print($text); return $r->OK; } } # INTERNAL METHOD: no POD # controller sub for register_static sub _static_helper { my $r = DW::Request->get; return DW::Template->render_template( $_[0]->args ); } # INTERNAL METHOD: no POD # controller sub for register_redirect sub _redirect_helper { my $r = DW::Request->get; my $data = $_[0]->args; my $dest = $data->{dest}; if ( ref $dest eq "CODE" ) { my $get = $r->get_args; $dest = $dest->( { map { $_ => LJ::eurl( $get->{$_} ) } keys %$get } ); } if ( $data->{full_uri} ) { return $r->redirect($dest); } else { return $r->redirect( LJ::create_url( $dest, keep_args => $data->{keep_args} ) ); } } =head1 Registration API =head2 C<< $class->register_static( $string, $filename, %opts ) >> Static page helper. =over =item string - path =item filename - template filename =item Opts ( see register_string ) =back =cut sub register_static { my ( $class, $string, $fn, %opts ) = @_; $opts{args} = $fn; $class->register_string( $string, \&_static_helper, %opts ); } =head2 C<< $class->register_string( $string, $sub, %opts ) >> =over =item string - path =item sub - sub =item Opts: =over =item args - passed verbatim to sub. =item app - Serve this in app-space. =item user - Serve this in journalspace. =item format - What format should be used, defaults to HTML =item formats - An array of possible formats, or 1 to allow everything. =back =back =cut sub register_string { my ( $class, $string, $sub, %opts ) = @_; my $hash = _apply_defaults( \%opts, { sub => $sub, } ); $string_choices{ 'app' . $string } = $hash if $hash->{app}; $string_choices{ 'user' . $string } = $hash if $hash->{user}; my %redirect_opts = ( app => $hash->{app}, user => $hash->{user}, formats => $hash->{formats}, format => $hash->{format}, no_redirects => 1, keep_args => 1, ); if ( $string =~ m!(^(.*)/)index$! && !exists $opts{no_redirects} ) { $class->register_redirect( $2, $1, %redirect_opts ) if $2; $string_choices{ 'app' . $1 } = $hash if $hash->{app}; $string_choices{ 'user' . $1 } = $hash if $hash->{user}; } elsif ( !exists $opts{no_redirects} ) { # for all other (non-index) pages, redirect page/ to page $class->register_redirect( "$string/", $string, %redirect_opts ); } } =head2 C<< $class->register_redirect( $string, $dest, %opts ) >> Redirect helper. =over =item string - path =item dest - destination =item Opts ( see register_string ) =over =item keep_args - Persist GET arguments over redirect ( same as the keep_args argument to create_url ). =item full_uri - A full URI ( http://...../foo v.s. /foo ) =back =back =cut sub register_redirect { my ( $class, $string, $dest, %opts ) = @_; my $args = { dest => $dest }; $args->{keep_args} = delete $opts{keep_args} || 0; $args->{full_uri} = delete $opts{full_uri} || 0; $opts{args} = $args; $class->register_string( $string, \&_redirect_helper, %opts ); } =head2 C<< $class->register_regex( $regex, $sub, %opts ) >> =over =item regex =item sub - sub =item Opts ( see register_string ) =back =cut sub register_regex { my ( $class, $regex, $sub, %opts ) = @_; my $hash = _apply_defaults( \%opts, { regex => $regex, sub => $sub, } ); push @{ $regex_choices{app} }, $hash if $hash->{app}; push @{ $regex_choices{user} }, $hash if $hash->{user}; push @{ $regex_choices{api} }, $hash if $hash->{api}; } =head2 C<< $class->register_rpc( $name, $sub, %opts ) >> Register a RPC call =over =item name - RPC call name =item sub - sub =item Opts ( see register_string ) =back =cut sub register_rpc { my ( $class, $string, $sub, %opts ) = @_; delete $opts{app}; delete $opts{user}; $class->register_string( "/__rpc_$string", $sub, app => 1, user => 1, %opts ); # FIXME: per Bug 4900, this line is temporary and can go away as soon as # all the javascript is updated $class->register_regex( qr!^/[^/]+/\Q__rpc_$string\E$!, $sub, app => 1, user => 1, %opts ); } =head2 C<< $class->register_api_endpoint( $string, $sub, %opts ) >> =over =item string =item sub - sub =item opts (see register_string) =back =cut sub register_api_endpoint { my ( $class, $string, $sub, %opts ) = @_; croak 'register_api_endpoint must have version option' unless exists $opts{version}; my $hash = _apply_defaults( \%opts, { sub => $sub, format => 'json', } ); my $vers = ref $opts{version} eq 'ARRAY' ? $opts{version} : [ $opts{version} + 0 ]; croak 'register_api_version requires all versions >= 1' if grep { $_ <= 0 } @$vers; # Now register this string at all versions that they gave us. $api_endpoints{$_}->{$string} = $hash foreach @$vers; } # internal helper for speed construction ... sub register_api_endpoints { my $class = shift; foreach my $row (@_) { $class->register_api_endpoint( $row->[0], $row->[1], version => $row->[2] ); } } =head2 C<< $class->register_api_rest_endpoint( $string, $sub, %opts ) >> =over =item string =item sub - sub =item opts (see register_regex) =back =cut sub register_api_rest_endpoint { my ( $class, $string, $sub, $controller_class, %opts ) = @_; croak 'register_api_rest_endpoint must have version option' unless exists $opts{version}; my $hash = _apply_defaults( \%opts, { class => $controller_class, sub => $sub, format => 'json', } ); my $vers = ref $opts{version} eq 'ARRAY' ? $opts{version} : [ $opts{version} + 0 ]; croak 'register_api_version requires all versions >= 1' if grep { $_ <= 0 } @$vers; # Now register this string at all versions that they gave us. $api_rest_endpoints{$_}->{$string} = $hash foreach @$vers; } # internal helper for speed construction ... sub register_api_rest_endpoints { my $class = shift; foreach my $row (@_) { $class->register_api_rest_endpoint( $row->[0], $row->[1], $row->[2], version => $row->[3] ); } } # internal method, intentionally no POD # applies default for opts and hash sub _apply_defaults { my ( $opts, $hash ) = @_; $hash ||= {}; $opts->{app} = 1 if !defined $opts->{app} && !$opts->{user} && !$opts->{api}; $hash->{args} = $opts->{args}; $hash->{app} = $opts->{app} || 0; $hash->{user} = $opts->{user} || 0; $hash->{api} = $opts->{api} || 0; $hash->{format} ||= $opts->{format} || 'html'; $hash->{no_cache} = $opts->{no_cache} || 0; my $formats = $opts->{formats} || [ $hash->{format} ]; $formats = { map { ( $_, 1 ) } @$formats } if ref $formats eq 'ARRAY'; $hash->{formats} = $formats; $hash->{methods} = $opts->{methods} || { GET => 1, POST => 1, HEAD => 1, DELETE => 1 }; return $hash; } =head1 AUTHOR =over =item Andrea Nall =item Mark Smith =back =head1 COPYRIGHT AND LICENSE Copyright (c) 2009-2013 by Dreamwidth Studios, LLC. This program is free software; you may redistribute it and/or modify it under the same terms as Perl itself. For a copy of the license, please reference 'perldoc perlartistic' or 'perldoc perlgpl'. =cut 1;