mourningdove/cgi-bin/DW/EmailPost/Entry.pm
2026-05-24 01:03:05 +00:00

557 lines
19 KiB
Perl

#!/usr/bin/perl
#
# Authors:
# Afuna <coder.dw@afunamatata.com>
#
# Copyright (c) 2013-2018 by Dreamwidth Studios, LLC.
#
# This code is a refactoring and extension of code originally forked
# from the LiveJournal project owned and operated by Live Journal, Inc.
# The code has been refactored, modified, and expanded by Dreamwidth
# Studios, LLC. These files were originally licensed under the terms
# of the license supplied by Live Journal, Inc, which can currently
# be found at:
#
# http://code.livejournal.org/trac/livejournal/browser/trunk/LICENSE-LiveJournal.txt
#
# In accordance with the original license, this code and all its
# modifications are provided under the GNU General Public License.
# A copy of that license can be found in the LICENSE file included as
# part of this distribution.
#
# This file is a refactoring of "cgi-bin/LJ/Emailpost.pm"
# from the original LiveJournal repository
package DW::EmailPost::Entry;
use base qw(DW::EmailPost::Base);
use strict;
use LJ::Protocol;
use Date::Parse;
use IO::Handle;
use XML::Simple;
use DW::Media;
my $workdir = "/tmp";
=head1 NAME
DW::EmailPost::Entry - Handle entries posted through email
=cut
sub _find_destination {
my ( $class, @to_addresses ) = @_;
foreach my $dest (@to_addresses) {
next unless $dest =~ /^(\S+?)\@\Q$LJ::EMAIL_POST_DOMAIN\E$/i;
return $1;
}
return;
}
sub _parse_destination {
my ( $self, $auth_string ) = @_;
# user and journal
my ( $user, $journal, $pin );
# ignore pin, handle it later
( $user, $pin ) = split /\+/, $auth_string;
( $user, $journal ) = split /\./, $user if $user =~ /\./;
$self->{u} = LJ::load_user($user);
return unless $self->{u};
$self->{journal} = $journal || $self->{u}->user;
return 1;
}
sub _process {
my $self = $_[0];
$self->_extract_pin;
$self->_check_pin_validity or return $self->send_error;
$self->_cleanup_mobile_carriers or return $self->send_error;
# figure out what entryprops should be based on post headers in email
# and user's defaults
$self->_set_props( $self->{u}, $self->{email_date}, %{ $self->{post_headers} || {} } )
or return $self->send_error;
# insert any images.
# must be done after we've processed the props, to make sure we respect security settings
$self->insert_images;
# do a final cleanup of the body text
$self->cleanup_body_final;
# build the entry
my $time = $self->{time};
my $req = {
usejournal => $self->{journal},
ver => 1,
username => $self->{u}->user,
event => $self->{body},
subject => $self->{subject},
security => $self->{security},
allowmask => $self->{amask},
props => $self->{props},
tz => $time->{zone},
year => $time->{year} + 1900,
mon => $time->{mon} + 1,
day => $time->{day},
hour => $time->{hour},
min => $time->{min},
};
# post!
my $post_error;
LJ::Protocol::do_request( "postevent", $req, \$post_error,
{ noauth => 1, allow_truncated_subject => 1 } );
return $self->send_error( LJ::Protocol::error_message($post_error) ) if $post_error;
$self->dblog( s => $self->{subject} );
return ( 1, "Post success" );
}
sub _extract_pin {
my $self = $_[0];
my ( undef, $pin ) = split /\+/, $self->{destination};
$self->{pin} = $pin;
# Strip (and maybe use) pin data from viewable areas
my $strip_pin = sub {
my $textref = $_[0];
my $pin;
if ( $$textref =~ s/^\s*\+([a-z0-9]+)\b//i ) {
$pin = $1;
}
return $pin;
};
$self->{pin} ||= $strip_pin->( \$self->{subject} );
$self->{pin} ||= $strip_pin->( \$self->{body} );
}
sub _set_props {
my ( $self, $u, $email_date, %post_headers ) = @_;
my $props = {};
my $time = {};
# Pull the Date: header details
my ( $ss, $mm, $hh, $day, $month, $year, $zone ) = strptime($email_date);
# If we had an lj/post-date pseudo header, override the real Date header
( $ss, $mm, $hh, $day, $month, $year, $zone ) = strptime( $post_headers{date} )
if $post_headers{date};
# TZ is parsed into seconds, we want something more like -0800
$zone = defined $zone ? sprintf( '%+05d', $zone / 36 ) : 'guess';
$time = {
sec => $ss,
min => $mm,
hour => $hh,
day => $day,
mon => $month,
year => $year,
zone => $zone,
};
$u->preload_props(
qw/
emailpost_userpic emailpost_security
emailpost_comments emailpost_gallery
/
);
# Get post options, using post-headers first, and falling back
# to user props. If neither exist, the regular journal defaults
# are used.
$props->{taglist} = $post_headers{tags};
$props->{picture_keyword} =
$post_headers{userpic}
|| $post_headers{icon}
|| $u->{emailpost_userpic};
if ( my $id = DW::Mood->mood_id( $post_headers{mood} ) ) {
$props->{current_moodid} = $id;
}
else {
$props->{current_mood} = $post_headers{mood};
}
$props->{current_music} = $post_headers{music};
$props->{current_location} = $post_headers{location};
$props->{editor} = $self->_choose_editor( $post_headers{format} );
$props->{opt_nocomments} = 1
if $post_headers{comments} =~ /off/i
|| $u->{emailpost_comments} =~ /off/i;
$props->{opt_noemail} = 1
if $post_headers{comments} =~ /noemail/i
|| $u->{emailpost_comments} =~ /noemail/i;
if ( exists $post_headers{screenlevel} ) {
if ( $post_headers{screenlevel} =~ /^all$/i ) {
$props->{opt_screening} = 'A';
}
elsif ( $post_headers{screenlevel} =~ /^untrusted$/i ) {
$props->{opt_screening} = 'F';
}
elsif ( $post_headers{screenlevel} =~ /^(anonymous|anon)$/i ) {
$props->{opt_screening} = 'R'; # needs-Remote
}
elsif ( $post_headers{screenlevel} =~ /^(disabled|none)$/i ) {
$props->{opt_screening} = 'N';
}
elsif ( $post_headers{screenlevel} ne '' ) {
$props->{opt_screening} = 'A';
$self->send_error(
"Unrecognized screening keyword. Your entry was posted with all comments screened.",
{ nolog => 1 }
);
}
else { # blank
$props->{opt_screening} = ''; # User default
}
}
else { # unspecified
$props->{opt_screening} = ''; # User default
}
my $security;
my $amask;
# "lc" is right here because groupnames are forcibly lowercased in
# LJ::User->trust_groups;
$security = lc $post_headers{security}
|| $u->emailpost_security; # FIXME: relies on emailpost_security ne 'usemask'?
if ( $security =~ /^(public|private|friends|access)$/ ) {
if ( $1 eq 'friends' or $1 eq 'access' ) {
$security = 'usemask';
$amask = 1;
}
}
elsif ($security) { # Assume a trust group list if unknown security.
# Get the mask for the requested trust group list, discarding those
# that don't exist.
$amask = 0;
my @unrecognized = ();
foreach my $groupname ( split( /\s*,\s*/, $security ) ) {
my $group = $u->trust_groups( name => $groupname );
if ($group) {
$amask |= ( 1 << $group->{groupnum} );
}
else {
push @unrecognized, $groupname;
}
}
$security = 'usemask';
if (@unrecognized) {
# send the error, but not shortcircuiting the posting process
# probably the only time that we call $self->send_error inside of a convenience sub
my $unrecognized = join( ', ', @unrecognized );
$self->send_error(
"Access group(s) \"$unrecognized\" not found. Your journal entry was posted to the other groups, or privately if no groups exist.",
{ nolog => 1 }
);
}
}
$self->{props} = $props;
$self->{security} = $security;
$self->{amask} = $amask;
$self->{time} = $time;
return 1;
}
=head2 C<< $self->insert_images >>
Take images from the email body and insert them into the entry
=cut
# could hypothetically be refactored out into Base.pm so that other subclasses could use
# but you'd probably want to pass in the variables instead of referring to $self
sub insert_images {
my ($self) = @_;
# upload picture attachments
# undef return value? retry posting for later.
my $fb_upload = $self->_upload_images(
security => $self->{security},
allowmask => $self->{amask},
);
# if we found and successfully uploaded some images...
if ( ref $fb_upload eq 'ARRAY' ) {
my $fb_html = join( '<br />', map { '<img src="' . $_->url . '" />' } @$fb_upload );
##
## A problem was here:
## $body is utf-8 text without utf-8 flag (see Unicode::MapUTF8::to_utf8),
## $fb_html is ASCII with utf-8 flag on (because uploaded image description
## is parsed by XML::Simple, see cgi-bin/fbupload.pl, line 153).
## When 2 strings are concatenated, $body is auto-converted (incorrectly)
## from Latin-1 to UTF-8.
##
$fb_html = Encode::encode( "utf8", $fb_html ) if Encode::is_utf8($fb_html);
$self->{body} .= '<br />' . $fb_html;
}
# 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.
$self->{body} .= "\n";
$self->{body} .= "(Your picture was not posted)";
}
}
# 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 ( $self, %opts ) = @_;
my @imgs = $self->get_entity( $self->{_entity}, 'image' );
return 1 unless scalar @imgs;
return 1401 unless DW::Media->can_upload_media( $self->{u} ); # error code from insert_images
my @images;
foreach my $img_entity (@imgs) {
my $obj = DW::Media->upload_media(
user => $self->{u},
data => $img_entity->bodyhandle->as_string,
%opts, # Should contain security.
);
push @images, $obj if $obj;
}
return unless scalar @images;
return \@images;
}
sub _check_pin_validity {
my $self = $_[0];
my $from = $self->{from};
my $pin = $self->{pin};
my $u = $self->{u};
my $addrlist = LJ::Emailpost::Web::get_allowed_senders( $self->{u} );
unless ( ref $addrlist && keys %$addrlist ) {
return $self->err( "No allowed senders have been saved for your account.",
{ nomail => 1 } );
return;
}
return $self->err("Unauthorized sender address: $from")
unless grep { lc $from eq lc $_ } keys %$addrlist;
return $self->err("Unable to locate your PIN.")
unless $pin;
return $self->err("Invalid PIN.")
unless lc $pin eq lc $u->prop('emailpost_pin');
return 1;
}
sub _cleanup_mobile_carriers {
my $self = $_[0];
# 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)
my $return_path = $self->{return_path};
my $content_type = $self->{content_type};
my $tent = $self->{_tent};
if ( $return_path =~ /(?:messaging|pm)\.sprint(?:pcs)?\.com/
&& $content_type->{"_orig"} =~ m#^multipart/alternative#i )
{
$tent = $self->get_entity( $self->{_entity}, 'html' );
return $self->err("Unable to find Sprint HTML content in PictureMail message.")
unless $tent;
# ok, parse the XML.
my $html = $tent->bodyhandle->as_string();
my $xml_string;
$xml_string = $1 if $html =~ /<!-- lsPictureMail-Share-\w+-comment\n(.+)\n-->/is;
return $self->err("Unable to find XML content in PictureMail message.")
unless $xml_string;
LJ::dhtml($xml_string); # $xml_string is being modified by this function call
# special characters are replaced with equivalent HTML entities
my $xml = eval { XML::Simple::XMLin($xml_string); };
return $self->err("Unable to parse XML content in PictureMail message.")
if !$xml || $@;
return $self->err("Sorry, we currently only support image media.")
unless $xml->{messageContents}->{type} eq 'PICTURE';
my $url =
LJ::dhtml( $xml->{messageContents}->{mediaItems}->{mediaItem}->{url} );
$url = LJ::trim($url);
$url =~ s#</?url>##g;
return $self->err("Invalid remote SprintPCS URL.")
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 => $workdir );
( undef, $tempfile ) = File::Temp::tempfile(
'sprintpcs_XXXXX',
SUFFIX => '.jpg',
OPEN => 0,
DIR => $tmpdir
);
my $ua = LJ::get_useragent(
role => 'emailgateway',
timeout => 20,
);
$ua->agent("Mozilla");
my $ua_rv = $ua->get( $url, ':content_file' => $tempfile );
$self->{body} = $xml->{messageContents}->{messageText};
$self->{body} = ref $self->{body} ? "" : LJ::dhtml( $self->{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)
$self->{subject} = 'Picture Post';
$self->{_entity} = MIME::Entity->build( Data => $self->{body} );
$self->{_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 $self->err(
"Unable to fetch SprintPCS image. ($reason)",
{
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$/ ) {
# if we aren't using their text/plain, then it's just
# advertising, and nothing else. kill it.
$self->{body} = "" if $tent->effective_type eq 'text/html';
# t-mobile has a variety of different file names, so we can't just allow "good"
# files through; rather, we can just strip out the bad filenames.
my @imgs;
foreach my $img ( $self->get_entity( $self->{_entity}, 'image' ) ) {
my $path = $img->bodyhandle->path;
$path =~ s!.*/!!;
next if $path =~ /^dottedline(350|600).gif$/;
next if $path =~ /^audio.gif$/;
next if $path =~ /^tmobilelogo.gif$/;
next if $path =~ /^tmobilespace.gif$/;
push @imgs, $img; # it's a good file if it made it this far.
}
$self->{entity}->parts( \@imgs );
}
# alltel. similar logic to t-mobile.
if ( $return_path && $return_path =~ /mms\.alltel\.net$/ ) {
my @imgs;
foreach my $img ( $self->get_entity( $self->{_entity}, 'image' ) ) {
my $path = $img->bodyhandle->path;
$path =~ s!.*/!!;
next if $path =~ /^divider\.gif$/;
next if $path =~ /^spacer\.gif$/;
next if $path =~ /^bluebar\.gif$/;
next if $path =~ /^header\.gif$/;
next if $path =~ /^greenbar\.gif$/;
next if $path =~ /^alltel_logo\.jpg$/;
push @imgs, $img; # it's a good file if it made it this far.
}
$self->{_entity}->parts( \@imgs );
}
# verizon crap. remove paragraphs of text.
$self->{body} =~ s/This message was sent using.+?Verizon.+?faster download\.//s;
# virgin mobile adds text to the *top* of the message, killing post-headers.
# Kill this silly (and grammatically incorrect) string.
if ( $return_path && $return_path =~ /vmpix\.com$/ ) {
$self->{body} =~ s/^This is an? MMS message\.\s+//ms;
}
# UK service 'O2' does some bizarre stuff.
# No concept of a subject - it uses the first 40 characters from the body,
# truncating the rest. The first text/plain is all advertising.
# The text/plain titled 'smil.txt' is the actual body of the message.
if ( $return_path && $return_path =~ /mediamessaging\.o2\.co\.uk$/ ) {
foreach my $ent ( $self->get_entity( $self->{_entity}, '*' ) ) {
my $path = $ent->bodyhandle->path;
$path =~ s#.*/##;
if ( $path eq 'smil.txt' ) {
$self->{body} = $ent->bodyhandle->as_string();
last;
}
}
$self->{subject} = 'Picture Post';
}
return 1;
}
1;