#!/usr/bin/perl
#
# DW::Request::Base
#
# Methods that are the same over most or all DW::Request modules
#
# Authors:
#      Andrea Nall <anall@andreanall.com>
#
# Copyright (c) 2010 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::Request::Base;

use strict;
use Carp qw/ croak confess cluck /;
use CGI::Cookie;
use CGI::Util qw( unescape );
use LJ::JSON;

use fields (
    'cookies_in',
    'cookies_in_multi',

    # If you use post_args, then you must not use content. If you use
    # content, you must not use post_args. Mutually exclusive.
    'content',      # raw content of the request (POST only!)
    'post_args',    # hashref of POST arguments (form encoding)
    'json_obj',     # JSON object that was posted (application/json)
    'uploads',      # arrayref of hashrefs of uploaded files

    # Query string arguments, every request might have these.
    'get_args',
    'msgs',
    'msgkey',
);

sub new {
    my $self = $_[0];
    confess "This is a base class, you can't use it directly."
        unless ref $self;

    $self->{cookies_in}       = undef;
    $self->{cookies_in_multi} = undef;
    $self->{post_args}        = undef;
    $self->{content}          = undef;
    $self->{get_args}         = undef;
    $self->{json_obj}         = undef;
    $self->{uploads}          = undef;
    $self->{msgs}             = undef;
    $self->{msgkey}           = undef;
}

sub host {
    return lc( $_[0]->header_in("Host") // "" );
}

sub cookie {
    my DW::Request::Base $self = $_[0];

    $self->parse( $self->header_in('Cookie') ) unless defined $self->{cookies_in};
    my $val = $self->{cookies_in}->{ $_[1] } || [];
    return wantarray ? @$val : $val->[0];
}

sub cookie_multi {
    my DW::Request::Base $self = $_[0];

    $self->parse( $self->header_in('Cookie') ) unless defined $self->{cookies_in_multi};
    return @{ $self->{cookies_in_multi}->{ $_[1] } || [] };
}

sub add_cookie {
    my DW::Request::Base $self = shift;
    my %args = (@_);

    confess "Must provide name" unless $args{name};
    confess "Must provide value (try delete_cookie if you really mean this)"
        unless exists $args{value};

    # if the domain is just '.', remove it since that means 'current domain'.
    # this is primarily needed for devcontainer since that uses localhost.
    delete $args{domain} if defined $args{domain} && $args{domain} eq '.';

    # we need to give all cookies the secure attribute on HTTPS sites
    if ( $LJ::PROTOCOL eq "https" ) {
        $args{secure} = 1;
    }
    else {    # if we're not secure, hopefully we're in a development environment
        $args{SameSite} = 'Lax' if $LJ::IS_DEV_SERVER;

        # TODO: test and see if the site works as expected with
        # SameSite=Lax turned on for all cookies - Lax prevents
        # cross-domain POST requests but GETs are allowed. Not
        # setting it at all is equivalent to SameSite=None, which
        # newer browsers only allow if the secure attribute is set.
    }

    # extraneous parenthesis inside map {} needed to force BLOCK mode map
    my $cookie = CGI::Cookie->new( map { ( "-$_" => $args{$_} ) } keys %args );
    $self->err_header_out_add( 'Set-Cookie' => $cookie );

    return $cookie;
}

sub delete_cookie {
    my DW::Request::Base $self = shift;
    my %args = (@_);

    confess "Must provide name" unless $args{name};

    $args{value}   = '';
    $args{expires} = "-1d";

    return $self->add_cookie(%args);
}

# Per RFC, method must be GET, POST, etc. We don't allow lowercase or any other
# presentation of the method to count as a post.
sub did_post {
    my DW::Request::Base $self = $_[0];
    return $self->method eq 'POST';
}

# Returns an array of uploads that were received in this request. Each upload
# is a hashref of certain data: body, name.
sub uploads {
    my DW::Request::Base $self = $_[0];
    return $self->{uploads} if defined $self->{uploads};

    my $body = $self->content;
    return $self->{uploads} = []
        unless $body && $self->method eq 'POST';

    my $sep =
        ( $self->header_in('Content-Type') =~ m!^multipart/form-data;\s*boundary=(\S+)! )
        ? $1
        : undef;
    croak 'Unknown content type in upload.' unless defined $sep;

    my @lines = split /\r\n/, $body;
    my $line  = shift @lines;
    croak 'Error parsing upload, it looks invalid.'
        unless $line eq "--$sep";

    my $ret = [];
    while (@lines) {
        $line = shift @lines;

        my %h;
        while ( defined $line && $line ne "" ) {
            $line =~ /^(\S+?):\s*(.+)/;
            $h{ lc($1) } = $2;
            $line = shift @lines;
        }
        while ( defined $line && $line ne "--$sep" ) {
            last if $line eq "--$sep--";
            $h{body} .= "\r\n" if $h{body};
            $h{body} .= $line;
            $line = shift @lines;
        }
        if ( $h{'content-disposition'} =~ /name="(\S+?)"/ ) {
            $h{name} = $1 || $2;
            push @$ret, \%h;
        }
    }

    return $self->{uploads} = $ret;
}

# returns a Hash::MultiValue object containing the post arguments if this is a
# valid request, or it returns undef.
sub post_args {
    my DW::Request::Base $self = $_[0];
    return $self->{post_args} if defined $self->{post_args};

    # Requires a POST with the proper content type for us to parse it, else just
    # bail and return empty.
    return Hash::MultiValue->new
        unless $self->method eq 'POST'
        && $self->header_in('Content-Type') =~ m!^application/x-www-form-urlencoded(?:;.+)?$!;

    return $self->{post_args} = $self->_string_to_multivalue( $self->content );
}

# returns a Hash::MultiValue of query string arguments
sub get_args {
    my DW::Request $self = shift;
    return $self->{get_args} if defined $self->{get_args};

    my %opts = @_;

    # We lowercase GET arguments because these are often typed by users, and
    # that's nicer on them.  This isn't always desired behavior, though.
    # In particular, it confuses post_fields_by_widget in LJ::Widget.

    my $lc = $opts{preserve_case} ? 0 : 1;

    return $self->{get_args} =
        $self->_string_to_multivalue( $self->query_string, lowercase => $lc );
}

# Returns a JSON object contained in the body of this request if and only if
# this request contains a JSON object.
sub json {
    my DW::Request $self = $_[0];
    return $self->{json_obj} if defined $self->{json_obj};

    # Content type must start with "application/json" and may have a semi-colon
    # followed by charset, etc. It must also be a POST.
    return undef
        unless $self->method eq 'POST'
        && $self->header_in('Content-Type') =~ m!^application/json(?:;.+)?$!;

    # If they submit bad JSON, we want to ignore the error and not crash. Just
    # let the caller know it wasn't a valid input.
    my $obj;
    eval { $obj = from_json( $self->content ); };
    return undef if $@;

    # Temporarily caches it, in case someone tries to ask for it again.
    return $self->{json_obj} = $obj;
}

# FIXME: This relies on the behavior parse_args
#   and the \0 seperated arguments. This should be cleaned
#   up at the same point parse_args is.
sub _string_to_multivalue {
    my ( $class, $input, %opts ) = @_;
    my %gets = LJ::parse_args($input);

    my @out;
    foreach my $key ( keys %gets ) {

        my @parts = defined $gets{$key} ? split( /\0/, $gets{$key} ) : '';
        push @out, map { $opts{lowercase} ? lc $key : $key => $_ } @parts;
    }

    return Hash::MultiValue->new(@out);
}

# simply sets the location header and returns REDIRECT
sub redirect {
    my %opts = @_;
    my DW::Request $self = $_[0];
    $self->header_out( Location => $_[1] );
    return $opts{permanent} ? $self->MOVED_PERMANENTLY : $self->REDIRECT;
}

# Constants for message alert levels
sub DEFAULT { return INFO(); }
sub INFO    { return 'info'; }
sub WARN    { return 'warning'; }
sub WARNING { return WARN(); };     # alias because both are common usages.
sub ERROR   { return 'error'; }
sub SUCCESS { return 'success'; }
my @MSG_LEVELS = ( DEFAULT(), INFO(), WARN(), ERROR(), SUCCESS() );

# Generate memcache key for session messages
sub msgkey {
    my DW::Request $self = $_[0];
    return $self->{msgkey} if defined $self->{msgkey};

    my $cookie = $self->cookie('ljuniq');
    if ($cookie) {
        my ( $uniq, $ts ) = split( /:/, $self->cookie('ljuniq') );
        $self->{msgkey} = "req_msgs:$uniq";
    }
    return $self->{msgkey};
}

# Gets session messages to display inline on pages
sub msgs {
    my DW::Request $self = $_[0];

    return $self->{msgs} if defined $self->{msgs};
    my $msgkey = $self->msgkey;
    $self->{msgs} = LJ::MemCache::get($msgkey) if $msgkey;
    return $self->{msgs};
}

# Clear session messages from the request and from memcache.
# Should be used after messages have been displayed to user.
sub clear_msgs {
    my DW::Request $self = $_[0];

    my $msgkey = $self->msgkey;
    LJ::MemCache::delete($msgkey) if $msgkey;
    $self->{msgs} = undef;

    return 1;
}

# Add a session message to be displayed inline. Log level can be
# one of INFO, WARNING, ERROR, SUCCESS, or DEFAULT, or none.
sub add_msg {
    my DW::Request $self = $_[0];
    my $msg              = $_[1];
    my $level            = $_[2];

    croak "Invalid message level $level" if $level && !( grep { $level eq $_ } @MSG_LEVELS );
    $msg =
        $level ? { 'item' => $msg, 'level' => $level } : { 'item' => $msg, 'level' => DEFAULT() };

    my $msgs = $self->msgs;
    if ($msgs) {
        push @$msgs, $msg;
    }
    else {
        $msgs = [$msg];
    }

    my $msgkey = $self->msgkey;
    LJ::MemCache::set( $msgkey, $msgs ) if $msgkey;
    $self->{msgs} = $msgs;
    return 1;
}

# Add a session message and redirect. This is a helper
# method that wraps add_msg and redirect in one call.
sub msg_redirect {
    my DW::Request $self = $_[0];
    my $msg              = $_[1];
    my $level            = $_[2];
    my $location         = $_[3];

    $self->add_msg( $msg, $level );
    return $self->redirect($location);
}

# indicates that this request has been handled
sub OK { return 0; }

# HTTP status codes that we return in other methods
sub HTTP_OK                     { return 200; }
sub HTTP_CREATED                { return 201; }
sub MOVED_PERMANENTLY           { return 301; }
sub REDIRECT                    { return 302; }
sub NOT_FOUND                   { return 404; }
sub HTTP_GONE                   { return 410; }
sub SERVER_ERROR                { return 500; }
sub HTTP_UNAUTHORIZED           { return 401; }
sub HTTP_BAD_REQUEST            { return 400; }
sub HTTP_UNSUPPORTED_MEDIA_TYPE { return 415; }
sub HTTP_SERVER_ERROR           { return 500; }
sub HTTP_METHOD_NOT_ALLOWED     { return 405; }
sub FORBIDDEN                   { return 403; }

# Unimplemented method block. These are things that the derivative classes must
# implement. In the future, it'd be nice to roll as many of these up to the base
# as we can, but that's in the post-Apache days.
sub header_out {
    confess 'Unimplemented call on base class.';
}
*header_out_add     = \&header_out;
*err_header_out     = \&header_out;
*err_header_out_add = \&header_out;
*header_in          = \&header_out;
*header_in_add      = \&header_out;
*err_header_in      = \&header_out;
*err_header_in_add  = \&header_out;
*method             = \&header_out;

sub call_response_handler {

    # Default behavior is to call immediately
    return $_[1]->();
}

#
# Following sub was copied from CGI::Cookie and modified.
#
# Copyright 1995-1999, Lincoln D. Stein.  All rights reserved.
# It may be used and modified freely, but I do request that this copyright
# notice remain attached to the file.  You may modify this module as you
# wish, but if you redistribute a modified version, please attach a note
# listing the modifications you have made.
#
sub parse {
    my DW::Request::Base $self = $_[0];
    my %results;
    my %results_multi;

    my @pairs = split( "[;,] ?", defined $_[1] ? $_[1] : '' );
    foreach (@pairs) {
        $_ =~ s/\s*(.*?)\s*/$1/;
        my ( $key, $value ) = split( "=", $_, 2 );

        # Some foreign cookies are not in name=value format, so ignore
        # them.
        next unless defined($value);
        my @values = ();
        if ( $value ne '' ) {
            @values = map unescape($_), split( /[&;]/, $value . '&dmy' );
            pop @values;
        }
        $key = unescape($key);
        $results{$key} ||= \@values;
        push @{ $results_multi{$key} }, \@values;
    }

    $self->{cookies_in}       = \%results;
    $self->{cookies_in_multi} = \%results_multi;
}

1;
