ljr/livejournal/cgi-bin/ljemailgateway.pl

648 lines
23 KiB
Perl
Executable File

#!/usr/bin/perl
package LJ::Emailpost;
use strict;
use lib "$ENV{LJHOME}/cgi-bin";
BEGIN {
require 'ljconfig.pl';
if ($LJ::USE_PGP) {
eval 'use GnuPG::Interface';
die "Could not load GnuPG::Interface." if $@;
}
}
require 'ljlib.pl';
require 'ljprotocol.pl';
require 'fbupload.pl';
use HTML::Entities;
use IO::Handle;
use LWP::UserAgent;
use MIME::Words ();
use XML::Simple;
# $rv - scalar ref from mailgated.
# set to 1 to dequeue, 0 to leave for further processing.
sub process {
my ($entity, $to, $rv) = @_;
my (
# journal vars
$head, $user, $journal,
$pin, $u, $req, $post_error,
# email vars
$from, $addrlist, $return_path,
$body, $subject, $charset,
$format, $tent,
# pict upload vars
$fb_upload, $fb_upload_errstr,
);
$head = $entity->head;
$head->unfold;
$$rv = 1; # default dequeue
# Parse email for lj specific info
($user, $pin) = split(/\+/, $to);
($user, $journal) = split(/\./, $user) if $user =~ /\./;
$u = LJ::load_user($user);
return unless $u;
LJ::load_user_props($u, 'emailpost_pin') unless (lc($pin) eq 'pgp' && $LJ::USE_PGP);
# Pick what address to send potential errors to.
$addrlist = LJ::Emailpost::get_allowed_senders($u);
$from = ${(Mail::Address->parse( $head->get('From:') ))[0] || []}[1];
return unless $from;
my $err_addr;
foreach (keys %$addrlist) {
if (lc($from) eq lc &&
$addrlist->{$_}->{'get_errors'}) {
$err_addr = $from;
last;
}
}
$err_addr ||= $u->{email};
my $err = sub {
my ($msg, $opt) = @_;
# FIXME: Need to log last 10 errors to DB / memcache
# and create a page to watch this stuff.
my $errbody;
$errbody .= "There was an error during your email posting:\n\n";
$errbody .= $msg;
if ($body) {
$errbody .= "\n\n\nOriginal posting follows:\n\n";
$errbody .= $body;
}
# Rate limit email to 1/5min/address
if ($opt->{'sendmail'} && $err_addr &&
LJ::MemCache::add("rate_eperr:$err_addr", 5, 300)) {
LJ::send_mail({
'to' => $err_addr,
'from' => $LJ::BOGUS_EMAIL,
'fromname' => "$LJ::SITENAME Error",
'subject' => "$LJ::SITENAME posting error: $subject",
'body' => $errbody
});
}
$$rv = 0 if $opt->{'retry'};
return $msg;
};
# The return path should normally not ever be perverted enough to require this,
# but some mailers nowadays do some very strange things.
$return_path = ${(Mail::Address->parse( $head->get('Return-Path') ))[0] || []}[1];
# Use text/plain piece first - if it doesn't exist, then fallback to text/html
$tent = get_entity( $entity );
$tent = get_entity( $entity, 'html' ) unless $tent;
$body = $tent ? $tent->bodyhandle->as_string : "";
$body =~ s/^\s+//;
$body =~ s/\s+$//;
# Snag charset and do utf-8 conversion
my $content_type = $head->get('Content-type:');
$charset = $1 if $content_type =~ /\bcharset=['"]?(\S+?)['"]?[\s\;]/i;
$format = $1 if $content_type =~ /\bformat=['"]?(\S+?)['"]?[\s\;]/i;
if (defined($charset) && $charset !~ /^UTF-?8$/i) { # no charset? assume us-ascii
return $err->("Unknown charset encoding type.", { sendmail => 1 })
unless Unicode::MapUTF8::utf8_supported_charset($charset);
$body = Unicode::MapUTF8::to_utf8({-string=>$body, -charset=>$charset});
}
# check subject for rfc-1521 junk
$subject ||= $head->get('Subject:');
if ($subject =~ /^=\?/) {
my @subj_data = MIME::Words::decode_mimewords( $subject );
if (@subj_data) {
if ($subject =~ /utf-8/i) {
$subject = $subj_data[0][0];
} else {
$subject = Unicode::MapUTF8::to_utf8(
{
-string => $subj_data[0][0],
-charset => $subj_data[0][1]
}
);
}
}
}
# Strip (and maybe use) pin data from viewable areas
if ($subject =~ s/^\s*\+([a-z0-9]+)\s+//i) {
$pin = $1 unless defined $pin;
}
if ($body =~ s/^\s*\+([a-z0-9]+)\s+//i) {
$pin = $1 unless defined $pin;
}
# Validity checks. We only care about these if they aren't using PGP.
unless (lc($pin) eq 'pgp' && $LJ::USE_PGP) {
return $err->("No allowed senders have been saved for your account.") unless ref $addrlist;
# don't mail user due to bounce spam
return $err->("Unauthorized sender address: $from")
unless grep { lc($from) eq lc($_) } keys %$addrlist;
return $err->("Unable to locate your PIN.", { sendmail => 1 }) unless $pin;
return $err->("Invalid PIN.", { sendmail => 1 }) unless lc($pin) eq lc($u->{emailpost_pin});
}
return $err->("Email gateway access denied for your account type.", { sendmail => 1 })
unless LJ::get_cap($u, "emailpost");
# Is this message from a sprint PCS phone? Sprint doesn't support
# MMS (yet) - when it does, we should just be able to rip this block
# of code completely out.
#
# Sprint has two methods of non-mms mail sending.
# - Normal text messaging just sends a text/plain piece.
# - Sprint "PictureMail".
# PictureMail sends a text/html piece, that contains XML with
# the location of the image on their servers - and a text/plain as well.
# (The text/plain used to be blank, now it's really text/plain. We still
# can't use it, however, without heavy and fragile parsing.)
# We assume the existence of a text/html means this is a PictureMail message,
# as there is no other method (headers or otherwise) to tell the difference,
# and Sprint tells me that their text messaging never contains text/html.
# Currently, PictureMail can only contain one image per message
# and the image is always a jpeg. (2/2/05)
if ($return_path =~ /(?:messaging|pm)\.sprint(?:pcs)?\.com/ &&
$content_type =~ m#^multipart/alternative#i) {
$tent = get_entity( $entity, 'html' );
return $err->(
"Unable to find Sprint HTML content in PictureMail message.",
{ sendmail => 1 }
) unless $tent;
# ok, parse the XML.
my $html = $tent->bodyhandle->as_string();
my $xml_string = $1 if $html =~ /<!-- lsPictureMail-Share-\w+-comment\n(.+)\n-->/is;
return $err->(
"Unable to find XML content in PictureMail message.",
{ sendmail => 1 }
) unless $xml_string;
HTML::Entities::decode_entities( $xml_string );
my $xml = eval { XML::Simple::XMLin( $xml_string ); };
return $err->(
"Unable to parse XML content in PictureMail message.",
{ sendmail => 1 }
) if ( ! $xml || $@ );
return $err->(
"Sorry, we currently only support image media.",
{ sendmail => 1 }
) unless $xml->{messageContents}->{type} eq 'PICTURE';
my $url =
HTML::Entities::decode_entities(
$xml->{messageContents}->{mediaItems}->{mediaItem}->{content} );
$url = LJ::trim($url);
$url =~ s#</?url>##g;
return $err->(
"Invalid remote SprintPCS URL.", { sendmail => 1 }
) unless $url =~ m#^http://pictures.sprintpcs.com/#;
# we've got the url to the full sized image.
# fetch!
my ($tmpdir, $tempfile);
$tmpdir = File::Temp::tempdir( "ljmailgate_" . 'X' x 20, DIR=> $main::workdir );
( undef, $tempfile ) = File::Temp::tempfile(
'sprintpcs_XXXXX',
SUFFIX => '.jpg',
OPEN => 0,
DIR => $tmpdir
);
my $ua = LWP::UserAgent->new(
timeout => 20,
agent => 'Mozilla',
);
my $ua_rv = $ua->get( $url, ':content_file' => $tempfile );
$body = $xml->{messageContents}->{messageText};
$body = ref $body ? "" : HTML::Entities::decode( $body );
if ($ua_rv->is_success) {
# (re)create a basic mime entity, so the rest of the
# emailgateway can function without modifications.
# (We don't need anything but Data, the other parts have
# already been pulled from $head->unfold)
$subject = 'Picture Post';
$entity = MIME::Entity->build( Data => $body );
$entity->attach(
Path => $tempfile,
Type => 'image/jpeg'
);
}
else {
# Retry if we are unable to connect to the remote server.
# Otherwise, the image has probably expired. Dequeue.
my $reason = $ua_rv->status_line;
return $err->(
"Unable to fetch SprintPCS image. ($reason)",
{
sendmail => 1,
retry => $reason =~ /Connection refused/
}
);
}
}
# tmobile hell.
# if there is a message, then they send text/plain and text/html,
# with a slew of their tmobile specific images. If no message
# is attached, there is no text/plain piece, and the journal is
# polluted with their advertising. (The tmobile images (both good
# and junk) are posted to scrapbook either way.)
# gross. do our best to strip out the nasty stuff.
if ($return_path && $return_path =~ /tmomail\.net$/ &&
$head->get("X-Operator") =~ /^T-Mobile/i) {
# if we aren't using their text/plain, then it's just
# advertising, and nothing else. kill it.
$body = "" if $tent->effective_type eq 'text/html';
# strip all images but tmobile phone dated.
# 06-03-05_12394.jpg
my @imgs;
foreach my $img ( get_entity($entity, 'image') ) {
my $path = $img->bodyhandle->path;
$path =~ s#.*/##;
# intentionally not being explicit with regexp, in case
# they go to 4 digit year or whatever.
push @imgs, $img if $path =~ /^\d+-\d+-\d+_\d+.\w+$/;
}
$entity->parts(\@imgs);
}
# PGP signed mail? We'll see about that.
if (lc($pin) eq 'pgp' && $LJ::USE_PGP) {
my %gpg_errcodes = ( # temp mapping until translation
'bad' => "PGP signature found to be invalid.",
'no_key' => "You don't have a PGP key uploaded.",
'bad_tmpdir' => "Problem generating tempdir: Please try again.",
'invalid_key' => "Your PGP key is invalid. Please upload a proper key.",
'not_signed' => "You specified PGP verification, but your message isn't PGP signed!");
my $gpgerr;
my $gpgcode = LJ::Emailpost::check_sig($u, $entity, \$gpgerr);
unless ($gpgcode eq 'good') {
my $errstr = $gpg_errcodes{$gpgcode};
$errstr .= "\nGnuPG error output:\n$gpgerr\n" if $gpgerr;
return $err->($errstr, { sendmail => 1 });
}
# Strip pgp clearsigning and any extra text surrounding it
# This takes into account pgp 'dash escaping' and a possible lack of Hash: headers
$body =~ s/.*?^-----BEGIN PGP SIGNED MESSAGE-----(?:\n[^\n].*?\n\n|\n\n)//ms;
$body =~ s/-----BEGIN PGP SIGNATURE-----.+//s;
}
$body =~ s/^(?:\- )?[\-_]{2,}\s*\r?\n.*//ms; # trim sigs
$body =~ s/ \n/ /g if lc($format) eq 'flowed'; # respect flowed text
# trim off excess whitespace (html cleaner converts to breaks)
$body =~ s/\n+$/\n/;
# Find and set entry props.
my $props = {};
my (%lj_headers, $amask);
if ($body =~ s/^(lj-.+?)\n\n//is) {
map { $lj_headers{lc($1)} = $2 if /^lj-(\w+):\s*(.+?)\s*$/i } split /\n/, $1;
}
LJ::load_user_props(
$u,
qw/
emailpost_userpic emailpost_security
emailpost_comments emailpost_gallery
emailpost_imgsecurity /
);
# Get post options, using lj-headers first, and falling back
# to user props. If neither exist, the regular journal defaults
# are used.
$props->{taglist} = $lj_headers{tags};
$props->{picture_keyword} = $lj_headers{'userpic'} ||
$u->{'emailpost_userpic'};
$props->{current_mood} = $lj_headers{'mood'};
$props->{current_music} = $lj_headers{'music'};
$props->{opt_nocomments} = 1
if $lj_headers{comments} =~ /off/i
|| $u->{'emailpost_comments'} =~ /off/i;
$props->{opt_noemail} = 1
if $lj_headers{comments} =~ /noemail/i
|| $u->{'emailpost_comments'} =~ /noemail/i;
$lj_headers{security} = lc($lj_headers{security}) || $u->{'emailpost_security'};
if ($lj_headers{security} =~ /^(public|private|friends)$/) {
if ($1 eq 'friends') {
$lj_headers{security} = 'usemask';
$amask = 1;
}
} elsif ($lj_headers{security}) { # Assume a friendgroup if unknown security mode.
# Get the mask for the requested friends group, or default to private.
my $group = LJ::get_friend_group($u, { 'name'=>$lj_headers{security} });
if ($group) {
$amask = (1 << $group->{groupnum});
$lj_headers{security} = 'usemask';
} else {
$err->("Friendgroup \"$lj_headers{security}\" not found. Your journal entry was posted privately.",
{ sendmail => 1 });
$lj_headers{security} = 'private';
}
}
# if they specified a imgsecurity header but it isn't valid, default
# to private. Otherwise, set to what they specified.
$lj_headers{'imgsecurity'} = lc($lj_headers{'imgsecurity'}) ||
$u->{'emailpost_imgsecurity'} || 'public';
$lj_headers{'imgsecurity'} = 'private'
unless $lj_headers{'imgsecurity'} =~ /^(private|regusers|friends|public)$/;
# upload picture attachments to fotobilder.
# undef return value? retry posting for later.
$fb_upload = upload_images(
$entity, $u,
\$fb_upload_errstr,
{
imgsec => $lj_headers{'imgsecurity'},
galname => $lj_headers{'gallery'} || $u->{'emailpost_gallery'}
}
) || return $err->( $fb_upload_errstr, { retry => 1 } );
# if we found and successfully uploaded some images...
$body .= LJ::FBUpload::make_html( $u, $fb_upload, \%lj_headers )
if ref $fb_upload eq 'ARRAY';
# at this point, there are either no images in the message ($fb_upload == 1)
# or we had some error during upload that we may or may not want to retry
# from. $fb_upload contains the http error code.
if ( $fb_upload == 400 # bad http request
|| $fb_upload == 1401 # user has exceeded the fb quota
|| $fb_upload == 1402 # user has exceeded the fb quota
) {
# don't retry these errors, go ahead and post the body
# to the journal, postfixed with the remote error.
$body .= "\n";
$body .= "(Your picture was not posted: $fb_upload_errstr)";
}
# Fotobilder server error. Retry.
return $err->( $fb_upload_errstr, { retry => 1 } ) if $fb_upload == 500;
# build lj entry
$req = {
'usejournal' => $journal,
'ver' => 1,
'username' => $user,
'event' => $body,
'subject' => $subject,
'security' => $lj_headers{security},
'allowmask' => $amask,
'props' => $props,
'tz' => 'guess',
};
# post!
LJ::Protocol::do_request("postevent", $req, \$post_error, { noauth=>1 });
return $err->(LJ::Protocol::error_message($post_error), { sendmail => 1}) if $post_error;
return "Email post success";
}
# By default, returns first plain text entity from email message.
# Specifying a type will return an array of MIME::Entity handles
# of that type. (image, application, etc)
# Specifying a type of 'all' will return all MIME::Entities,
# regardless of type.
sub get_entity
{
my ($entity, $type) = @_;
# old arguments were a hashref
$type = $type->{'type'} if ref $type eq "HASH";
# default to text
$type ||= 'text';
my $head = $entity->head;
my $mime_type = $head->mime_type;
return $entity if $type eq 'text' && $mime_type eq "text/plain";
return $entity if $type eq 'html' && $mime_type eq "text/html";
my @entities;
# Only bother looking in messages that advertise attachments
my $mimeattach_re = qr{ m|^multipart/(?:alternative|signed|mixed|related)$| };
if ($mime_type =~ $mimeattach_re) {
my $partcount = $entity->parts;
for (my $i=0; $i<$partcount; $i++) {
my $alte = $entity->parts($i);
return $alte if $type eq 'text' && $alte->mime_type eq "text/plain";
return $alte if $type eq 'html' && $alte->mime_type eq "text/html";
push @entities, $alte if $type eq 'all';
if ($type eq 'image' &&
$alte->mime_type =~ m#^application/octet-stream#) {
my $alte_head = $alte->head;
my $filename = $alte_head->recommended_filename;
push @entities, $alte if $filename =~ /\.(?:gif|png|tiff?|jpe?g)$/;
}
push @entities, $alte if $alte->mime_type =~ /^$type/ &&
$type ne 'all';
# Recursively search through nested MIME for various pieces
if ($alte->mime_type =~ $mimeattach_re) {
if ($type =~ /^(?:text|html)$/) {
my $text_entity = get_entity($entity->parts($i), $type);
return $text_entity if $text_entity;
} else {
push @entities, get_entity($entity->parts($i), $type);
}
}
}
}
return @entities if $type ne 'text' && scalar @entities;
return;
}
# Verifies an email pgp signature as being valid.
# Returns codes so we can use the pre-existing err subref,
# without passing everything all over the place.
#
# note that gpg interaction requires gpg version 1.2.4 or better.
sub check_sig {
my ($u, $entity, $gpg_err) = @_;
LJ::load_user_props($u, 'public_key');
my $key = $u->{public_key};
return 'no_key' unless $key;
# Create work directory.
my $tmpdir = File::Temp::tempdir("ljmailgate_" . 'X' x 20, DIR=>$main::workdir);
return 'bad_tmpdir' unless -e $tmpdir;
my ($in, $out, $err, $status,
$gpg_handles, $gpg, $gpg_pid, $ret);
my $check = sub {
my %rets =
(
'NODATA 1' => 1, # no key or no signed data
'NODATA 2' => 2, # no signed content
'NODATA 3' => 3, # error checking sig (crc)
'IMPORT_RES 0' => 4, # error importing key (crc)
'BADSIG' => 5, # good crc, bad sig
'GOODSIG' => 6, # all is well
);
while (my $gline = <$status>) {
foreach (keys %rets) {
next unless $gline =~ /($_)/;
return $rets{$1};
}
}
return 0;
};
my $gpg_cleanup = sub {
close $in;
close $out;
waitpid $gpg_pid, 0;
undef foreach $gpg, $gpg_handles;
};
my $gpg_pipe = sub {
$_ = IO::Handle->new() foreach $in, $out, $err, $status;
$gpg_handles = GnuPG::Handles->new( stdin => $in, stdout=> $out,
stderr => $err, status=> $status );
$gpg = GnuPG::Interface->new();
$gpg->options->hash_init( armor=>1, homedir=>$tmpdir );
$gpg->options->meta_interactive( 0 );
};
# Pull in user's key, add to keyring.
$gpg_pipe->();
$gpg_pid = $gpg->import_keys( handles=>$gpg_handles );
print $in $key;
$gpg_cleanup->();
$ret = $check->();
if ($ret && $ret == 1 || $ret == 4) {
$$gpg_err .= " $_" while (<$err>);
return 'invalid_key';
}
my ($txt, $txt_f, $txt_e, $sig_e);
$txt_e = (get_entity($entity))[0];
return 'bad' unless $txt_e;
if ($entity->effective_type() eq 'multipart/signed') {
# attached signature
$sig_e = (get_entity($entity, 'application/pgp-signature'))[0];
$txt = $txt_e->as_string();
my $txt_fh;
($txt_fh, $txt_f) =
File::Temp::tempfile('plaintext_XXXXXXXX', DIR => $tmpdir);
print $txt_fh $txt;
close $txt_fh;
} # otherwise, it's clearsigned
# Validate message.
# txt_e->bodyhandle->path() is clearsigned message in its entirety.
# txt_f is the ascii text that was signed (in the event of sig-as-attachment),
# with MIME headers attached.
$gpg_pipe->();
$gpg_pid =
$gpg->wrap_call( handles => $gpg_handles,
commands => [qw( --trust-model always --verify )],
command_args => $sig_e ?
[$sig_e->bodyhandle->path(), $txt_f] :
$txt_e->bodyhandle->path()
);
$gpg_cleanup->();
$ret = $check->();
if ($ret && $ret != 6) {
$$gpg_err .= " $_" while (<$err>);
return 'bad' if $ret =~ /[35]/;
return 'not_signed' if $ret =~ /[12]/;
}
return 'good' if $ret == 6;
return undef;
}
# Upload images to a Fotobilder installation.
# Return codes:
# 1 - no images found in mime entity
# undef - failure during upload
# http_code - failure during upload w/ code
# hashref - { title => url } for each image uploaded
sub upload_images
{
my ($entity, $u, $rv, $opts) = @_;
return 1 unless LJ::get_cap($u, 'fb_can_upload') && $LJ::FB_SITEROOT;
my @imgs = get_entity($entity, 'image');
return 1 unless scalar @imgs;
my @images;
foreach my $img_entity (@imgs) {
my $img = $img_entity->bodyhandle;
my $path = $img->path;
my $result = LJ::FBUpload::do_upload(
$u, $rv,
{
path => $path,
rawdata => \$img->as_string,
imgsec => $opts->{'imgsec'},
galname => $opts->{'galname'},
}
);
# do upload() returned undef? This is a posting error
# that should most likely be retried, due to something
# wrong on our side of things.
return if ! defined $result && $$rv;
# http error during upload attempt
# decide retry based on error type in caller
return $result unless ref $result;
# examine $result for errors
if ($result->{Error}->{code}) {
$$rv = $result->{Error}->{content};
# add 1000 to error code, so we can easily tell the
# difference between fb protocol error and
# http error when checking results.
return $result->{Error}->{code} + 1000;
}
push @images, {
url => $result->{URL},
width => $result->{Width},
height => $result->{Height},
title => $result->{Title},
};
}
return \@images if scalar @images;
return;
}
1;