mourningdove/cgi-bin/DW/Routing.pm

689 lines
17 KiB
Perl
Raw Normal View History

2026-05-24 01:03:05 +00:00
#!/usr/bin/perl
#
# DW::Routing
#
# Module to allow calling non-BML controller/views.
#
# Authors:
# Andrea Nall <anall@andreanall.com>
# Mark Smith <mark@dreamwidth.org>
#
# 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 = "<b>[Error: $msg]</b>"
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<br/><br/>Additionally, while trying to render this error page:";
$text .= "\n<b>[Error 2: $msg2]</b>";
}
$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 <anall@andreanall.com>
=item Mark Smith <mark@dreamwidth.org>
=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;