ljr/livejournal/cgi-bin/ljfeed.pl

557 lines
22 KiB
Perl
Executable File

#!/usr/bin/perl
use strict;
package LJ::Feed;
my %feedtypes = (
rss => \&create_view_rss,
atom => \&create_view_atom,
foaf => \&create_view_foaf,
);
sub make_feed
{
my ($r, $u, $remote, $opts) = @_;
$opts->{pathextra} =~ s!^/(\w+)!!;
my $feedtype = $1;
my $viewfunc = $feedtypes{$feedtype};
unless ($viewfunc) {
$opts->{'handler_return'} = 404;
return undef;
}
$opts->{noitems} = 1 if $feedtype eq 'foaf';
$r->notes('codepath' => "feed.$feedtype") if $r;
my $dbr = LJ::get_db_reader();
my $user = $u->{'user'};
if ($u->{'journaltype'} eq "R" && $u->{'renamedto'} ne "") {
$opts->{'redir'} = LJ::journal_base($u->{'renamedto'}, $opts->{'vhost'}) . "/data/$feedtype";
return undef;
}
LJ::load_user_props($u, qw/ journaltitle journalsubtitle opt_synlevel /);
LJ::text_out(\$u->{$_})
foreach ("name", "url", "urlname");
# opt_synlevel will default to 'full'
$u->{'opt_synlevel'} = 'full'
unless $u->{'opt_synlevel'} =~ /^(?:full|summary|title)$/;
# some data used throughout the channel
my $journalinfo = {
u => $u,
link => LJ::journal_base($u) . "/",
title => $u->{journaltitle} || $u->{name} || $u->{user},
subtitle => $u->{journalsubtitle} || $u->{name},
builddate => LJ::time_to_http(time()),
};
# if we do not want items for this view, just call out
return $viewfunc->($journalinfo, $u, $opts)
if ($opts->{'noitems'});
# for syndicated accounts, redirect to the syndication URL
# However, we only want to do this if the data we're returning
# is similar. (Not FOAF, for example)
if ($u->{'journaltype'} eq 'Y') {
my $synurl = $dbr->selectrow_array("SELECT synurl FROM syndicated WHERE userid=$u->{'userid'}");
unless ($synurl) {
return 'No syndication URL available.';
}
$opts->{'redir'} = $synurl;
return undef;
}
## load the itemids
my @itemids;
my @items = LJ::get_recent_items({
'clusterid' => $u->{'clusterid'},
'clustersource' => 'slave',
'remote' => $remote,
'userid' => $u->{'userid'},
'itemshow' => 25,
'order' => "logtime",
'itemids' => \@itemids,
'friendsview' => 1, # this returns rlogtimes
'dateformat' => "S2", # S2 format time format is easier
});
$opts->{'contenttype'} = 'text/xml; charset='.$opts->{'saycharset'};
### load the log properties
my %logprops = ();
my $logtext;
my $logdb = LJ::get_cluster_reader($u);
LJ::load_log_props2($logdb, $u->{'userid'}, \@itemids, \%logprops);
$logtext = LJ::get_logtext2($u, @itemids);
# set last-modified header, then let apache figure out
# whether we actually need to send the feed.
my $lastmod = 0;
foreach my $item (@items) {
# revtime of the item.
my $revtime = $logprops{$item->{itemid}}->{revtime};
$lastmod = $revtime if $revtime > $lastmod;
# if we don't have a revtime, use the logtime of the item.
unless ($revtime) {
my $itime = $LJ::EndOfTime - $item->{rlogtime};
$lastmod = $itime if $itime > $lastmod;
}
}
$r->set_last_modified($lastmod) if $lastmod;
# use this $lastmod as the feed's last-modified time
# we would've liked to use something like
# LJ::get_timeupdate_multi instead, but that only changes
# with new updates and doesn't change on edits.
$journalinfo->{'modtime'} = $lastmod;
# regarding $r->set_etag:
# http://perl.apache.org/docs/general/correct_headers/correct_headers.html#Entity_Tags
# It is strongly recommended that you do not use this method unless you
# know what you are doing. set_etag() is expecting to be used in
# conjunction with a static request for a file on disk that has been
# stat()ed in the course of the current request. It is inappropriate and
# "dangerous" to use it for dynamic content.
if ((my $status = $r->meets_conditions) != Apache::Constants::OK()) {
$opts->{handler_return} = $status;
return undef;
}
# email address of journal owner, but respect their privacy settings
if ($u->{'allow_contactshow'} eq "Y" && $u->{'opt_whatemailshow'} ne "N" && $u->{'opt_mangleemail'} ne "Y") {
my $cemail;
# default to their actual email
$cemail = $u->{'email'};
# use their livejournal email if they have one
if ($LJ::USER_EMAIL && $u->{'opt_whatemailshow'} eq "L" &&
LJ::get_cap($u, "useremail") && ! $u->{'no_mail_alias'}) {
$cemail = "$u->{'user'}\@$LJ::USER_DOMAIN";
}
# clean it up since we know we have one now
$journalinfo->{email} = $cemail;
}
# load tags now that we have no chance of jumping out early
my $logtags = LJ::Tags::get_logtags($u, \@itemids);
my %posteru = (); # map posterids to u objects
LJ::load_userids_multiple([map { $_->{'posterid'}, \$posteru{$_->{'posterid'}} } @items], [$u]);
my @cleanitems;
ENTRY:
foreach my $it (@items)
{
# load required data
my $itemid = $it->{'itemid'};
my $ditemid = $itemid*256 + $it->{'anum'};
next ENTRY if $posteru{$it->{'posterid'}} && $posteru{$it->{'posterid'}}->{'statusvis'} eq 'S';
if ($LJ::UNICODE && $logprops{$itemid}->{'unknown8bit'}) {
LJ::item_toutf8($u, \$logtext->{$itemid}->[0],
\$logtext->{$itemid}->[1], $logprops{$itemid});
}
# see if we have a subject and clean it
my $subject = $logtext->{$itemid}->[0];
if ($subject) {
$subject =~ s/[\r\n]/ /g;
LJ::CleanHTML::clean_subject_all(\$subject);
}
# an HTML link to the entry. used if we truncate or summarize
my $readmore = "<b>(<a href=\"$journalinfo->{link}$ditemid.html\">Read more ...</a>)</b>";
# empty string so we don't waste time cleaning an entry that won't be used
my $event = $u->{'opt_synlevel'} eq 'title' ? '' : $logtext->{$itemid}->[1];
# clean the event, if non-empty
my $ppid = 0;
if ($event) {
# users without 'full_rss' get their logtext bodies truncated
# do this now so that the html cleaner will hopefully fix html we break
unless (LJ::get_cap($u, 'full_rss')) {
my $trunc = LJ::text_trim($event, 0, 80);
$event = "$trunc $readmore" if $trunc ne $event;
}
LJ::CleanHTML::clean_event(\$event,
{ 'preformatted' => $logprops{$itemid}->{'opt_preformatted'} });
# do this after clean so we don't have to about know whether or not
# the event is preformatted
if ($u->{'opt_synlevel'} eq 'summary') {
# assume the first paragraph is terminated by two <br> or a </p>
# valid XML tags should be handled, even though it makes an uglier regex
if ($event =~ m!((<br\s*/?\>(</br\s*>)?\s*){2})|(</p\s*>)!i) {
# everything before the matched tag + the tag itself
# + a link to read more
$event = $` . $& . $readmore;
}
}
if ($event =~ /<lj-poll-(\d+)>/) {
my $pollid = $1;
my $name = $dbr->selectrow_array("SELECT name FROM poll WHERE pollid=?",
undef, $pollid);
if ($name) {
LJ::Poll::clean_poll(\$name);
} else {
$name = "#$pollid";
}
$event =~ s!<lj-poll-$pollid>!<div><a href="$LJ::SITEROOT/poll/?id=$pollid">View Poll: $name</a></div>!g;
}
$ppid = $1
if $event =~ m!<lj-phonepost journalid=['"]\d+['"] dpid=['"](\d+)['"] />!;
}
my $mood;
if ($logprops{$itemid}->{'current_mood'}) {
$mood = $logprops{$itemid}->{'current_mood'};
} elsif ($logprops{$itemid}->{'current_moodid'}) {
$mood = LJ::mood_name($logprops{$itemid}->{'current_moodid'}+0);
}
my $createtime = $LJ::EndOfTime - $it->{rlogtime};
my $cleanitem = {
itemid => $itemid,
ditemid => $ditemid,
subject => $subject,
event => $event,
createtime => $createtime,
eventtime => $it->{alldatepart}, # ugly: this is of a different format than the other two times.
modtime => $logprops{$itemid}->{revtime} || $createtime,
comments => ($logprops{$itemid}->{'opt_nocomments'} == 0),
music => $logprops{$itemid}->{'current_music'},
mood => $mood,
ppid => $ppid,
tags => [ values %{$logtags->{$itemid} || {}} ],
};
push @cleanitems, $cleanitem;
}
# fix up the build date to use entry-time
$journalinfo->{'builddate'} = LJ::time_to_http($LJ::EndOfTime - $items[0]->{'rlogtime'}),
return $viewfunc->($journalinfo, $u, $opts, \@cleanitems);
}
# the creator for the RSS XML syndication view
sub create_view_rss
{
my ($journalinfo, $u, $opts, $cleanitems) = @_;
my $ret;
# header
$ret .= "<?xml version='1.0' encoding='$opts->{'saycharset'}' ?>\n";
$ret .= LJ::run_hook("bot_director", "<!-- ", " -->") . "\n";
$ret .= "<rss version='2.0' xmlns:lj='http://www.livejournal.org/rss/lj/1.0/'>\n";
# channel attributes
$ret .= "<channel>\n";
$ret .= " <title>" . LJ::exml($journalinfo->{title}) . "</title>\n";
$ret .= " <link>$journalinfo->{link}</link>\n";
$ret .= " <description>" . LJ::exml("$journalinfo->{title} - $LJ::SITENAME") . "</description>\n";
$ret .= " <managingEditor>" . LJ::exml($journalinfo->{email}) . "</managingEditor>\n" if $journalinfo->{email};
$ret .= " <lastBuildDate>$journalinfo->{builddate}</lastBuildDate>\n";
$ret .= " <generator>LiveJournal / $LJ::SITENAME</generator>\n";
# TODO: add 'language' field when user.lang has more useful information
### image block, returns info for their current userpic
if ($u->{'defaultpicid'}) {
my $pic = {};
LJ::load_userpics($pic, [ $u, $u->{'defaultpicid'} ]);
$pic = $pic->{$u->{'defaultpicid'}}; # flatten
$ret .= " <image>\n";
$ret .= " <url>$LJ::USERPIC_ROOT/$u->{'defaultpicid'}/$u->{'userid'}</url>\n";
$ret .= " <title>" . LJ::exml($journalinfo->{title}) . "</title>\n";
$ret .= " <link>$journalinfo->{link}</link>\n";
$ret .= " <width>$pic->{'width'}</width>\n";
$ret .= " <height>$pic->{'height'}</height>\n";
$ret .= " </image>\n\n";
}
my %posteru = (); # map posterids to u objects
LJ::load_userids_multiple([map { $_->{'posterid'}, \$posteru{$_->{'posterid'}} } @$cleanitems], [$u]);
# output individual item blocks
foreach my $it (@$cleanitems)
{
my $itemid = $it->{itemid};
my $ditemid = $it->{ditemid};
$ret .= "<item>\n";
$ret .= " <guid isPermaLink='true'>$journalinfo->{link}$ditemid.html</guid>\n";
$ret .= " <pubDate>" . LJ::time_to_http($it->{createtime}) . "</pubDate>\n";
$ret .= " <title>" . LJ::exml($it->{subject}) . "</title>\n" if $it->{subject};
$ret .= " <author>" . LJ::exml($journalinfo->{email}) . "</author>" if $journalinfo->{email};
$ret .= " <link>$journalinfo->{link}$ditemid.html</link>\n";
# omit the description tag if we're only syndicating titles
# note: the $event was also emptied earlier, in make_feed
unless ($u->{'opt_synlevel'} eq 'title') {
$ret .= " <description>" . LJ::exml($it->{event}) . "</description>\n";
}
if ($it->{comments}) {
$ret .= " <comments>$journalinfo->{link}$ditemid.html</comments>\n";
}
$ret .= " <category>$_</category>\n" foreach map { LJ::exml($_) } @{$it->{tags} || []};
# support 'podcasting' enclosures
$ret .= LJ::run_hook( "pp_rss_enclosure",
{ userid => $u->{userid}, ppid => $it->{ppid} }) if $it->{ppid};
# TODO: add author field with posterid's email address, respect communities
$ret .= " <lj:music>" . LJ::exml($it->{music}) . "</lj:music>\n" if $it->{music};
$ret .= " <lj:mood>" . LJ::exml($it->{mood}) . "</lj:mood>\n" if $it->{mood};
$ret .= "</item>\n";
}
$ret .= "</channel>\n";
$ret .= "</rss>\n";
return $ret;
}
# the creator for the Atom view
# keys of $opts:
# saycharset - required: the charset of the feed
# noheader - only output an <entry>..</entry> block. off by default
# apilinks - output AtomAPI links for posting a new entry or
# getting/editing/deleting an existing one. off by default
# TODO: define and use an 'lj:' namespace
sub create_view_atom
{
my ($journalinfo, $u, $opts, $cleanitems) = @_;
my $ret;
# prolog line
$ret .= "<?xml version='1.0' encoding='$opts->{'saycharset'}' ?>\n";
$ret .= LJ::run_hook("bot_director", "<!-- ", " -->");
# AtomAPI interface
my $api = $opts->{'apilinks'} ? "$LJ::SITEROOT/interface/atom" :
"$LJ::SITEROOT/users/$u->{user}/data/atom";
# header
unless ($opts->{'noheader'}) {
$ret .= "<feed version='0.3' xmlns='http://purl.org/atom/ns#'>\n";
# attributes
$ret .= "<title mode='escaped'>" . LJ::exml($journalinfo->{title}) . "</title>\n";
$ret .= "<tagline mode='escaped'>" . LJ::exml($journalinfo->{subtitle}) . "</tagline>\n"
if $journalinfo->{subtitle};
$ret .= "<link rel='alternate' type='text/html' href='$journalinfo->{link}' />\n";
# last update
$ret .= "<modified>" . LJ::time_to_w3c($journalinfo->{'modtime'}, 'Z')
. "</modified>";
# link to the AtomAPI version of this feed
$ret .= "<link rel='service.feed' type='application/x.atom+xml' title='";
$ret .= LJ::ehtml($journalinfo->{title});
$ret .= $opts->{'apilinks'} ? "' href='$api/feed' />" : "' href='$api' />";
if ($opts->{'apilinks'}) {
$ret .= "<link rel='service.post' type='application/x.atom+xml' title='Create a new post' href='$api/post' />";
}
}
# output individual item blocks
foreach my $it (@$cleanitems)
{
my $itemid = $it->{itemid};
my $ditemid = $it->{ditemid};
$ret .= " <entry xmlns=\"http://purl.org/atom/ns#\">\n";
# include empty tag if we don't have a subject.
$ret .= " <title mode='escaped'>" . LJ::exml($it->{subject}) . "</title>\n";
$ret .= " <id>urn:lj:$LJ::DOMAIN:atom1:$journalinfo->{u}{user}:$ditemid</id>\n";
$ret .= " <link rel='alternate' type='text/html' href='$journalinfo->{link}$ditemid.html' />\n";
if ($opts->{'apilinks'}) {
$ret .= "<link rel='service.edit' type='application/x.atom+xml' title='Edit this post' href='$api/edit/$itemid' />";
}
$ret .= " <created>" . LJ::time_to_w3c($it->{createtime}, 'Z') . "</created>\n"
if $it->{createtime} != $it->{modtime};
my ($year, $mon, $mday, $hour, $min, $sec) = split(/ /, $it->{eventtime});
$ret .= " <issued>" . sprintf("%04d-%02d-%02dT%02d:%02d:%02d",
$year, $mon, $mday,
$hour, $min, $sec) . "</issued>\n";
$ret .= " <modified>" . LJ::time_to_w3c($it->{modtime}, 'Z') . "</modified>\n";
$ret .= " <author>\n";
$ret .= " <name>" . LJ::exml($journalinfo->{u}{name}) . "</name>\n";
$ret .= " <email>" . LJ::exml($journalinfo->{email}) . "</email>\n" if $journalinfo->{email};
$ret .= " </author>\n";
$ret .= " <category term='$_' />\n" foreach map { LJ::exml($_) } @{$it->{tags} || []};
# if syndicating the complete entry
# -print a content tag
# elsif syndicating summaries
# -print a summary tag
# else (code omitted), we're syndicating title only
# -print neither (the title has already been printed)
# note: the $event was also emptied earlier, in make_feed
if ($u->{'opt_synlevel'} eq 'full') {
$ret .= " <content type='text/html' mode='escaped'>" . LJ::exml($it->{event}) . "</content>\n";
} elsif ($u->{'opt_synlevel'} eq 'summary') {
$ret .= " <summary type='text/html' mode='escaped'>" . LJ::exml($it->{event}) . "</summary>\n";
}
$ret .= " </entry>\n";
}
unless ($opts->{'noheader'}) {
$ret .= "</feed>\n";
}
return $ret;
}
# create a FOAF page for a user
sub create_view_foaf {
my ($journalinfo, $u, $opts) = @_;
my $comm = ($u->{journaltype} eq 'C');
my $ret;
# return nothing if we're not a user
unless ($u->{journaltype} eq 'P' || $comm) {
$opts->{handler_return} = 404;
return undef;
}
# set our content type
$opts->{contenttype} = 'application/rdf+xml; charset=' . $opts->{saycharset};
# setup userprops we will need
LJ::load_user_props($u, qw{
aolim icq yahoo jabber msn url urlname external_foaf_url
});
# create bare foaf document, for now
$ret = "<?xml version='1.0'?>\n";
$ret .= LJ::run_hook("bot_director", "<!-- ", " -->");
$ret .= "<rdf:RDF\n";
$ret .= " xml:lang=\"en\"\n";
$ret .= " xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n";
$ret .= " xmlns:rdfs=\"http://www.w3.org/2000/01/rdf-schema#\"\n";
$ret .= " xmlns:foaf=\"http://xmlns.com/foaf/0.1/\"\n";
$ret .= " xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n";
# precompute some values
my $digest = Digest::SHA1::sha1_hex('mailto:' . $u->{email});
# channel attributes
$ret .= ($comm ? " <foaf:Group>\n" : " <foaf:Person>\n");
$ret .= " <foaf:nick>$u->{user}</foaf:nick>\n";
if ($u->{bdate} && $u->{bdate} ne "0000-00-00" && !$comm && $u->{allow_infoshow} eq 'Y') {
my $bdate = $u->{bdate};
$bdate =~ s/^0000-//;
$ret .= " <foaf:dateOfBirth>$bdate</foaf:dateOfBirth>\n";
}
$ret .= " <foaf:mbox_sha1sum>$digest</foaf:mbox_sha1sum>\n";
$ret .= " <foaf:page>\n";
$ret .= " <foaf:Document rdf:about=\"$LJ::SITEROOT/userinfo.bml?user=$u->{user}\">\n";
$ret .= " <dc:title>$LJ::SITENAME Profile</dc:title>\n";
$ret .= " <dc:description>Full $LJ::SITENAME profile, including information such as interests and bio.</dc:description>\n";
$ret .= " </foaf:Document>\n";
$ret .= " </foaf:page>\n";
# we want to bail out if they have an external foaf file, because
# we want them to be able to provide their own information.
if ($u->{external_foaf_url}) {
$ret .= " <rdfs:seeAlso rdf:resource=\"" . LJ::eurl($u->{external_foaf_url}) . "\" />\n";
$ret .= ($comm ? " </foaf:Group>\n" : " </foaf:Person>\n");
$ret .= "</rdf:RDF>\n";
return $ret;
}
# contact type information
my %types = (
aolim => 'aimChatID',
icq => 'icqChatID',
yahoo => 'yahooChatID',
msn => 'msnChatID',
jabber => 'jabberID',
);
if ($u->{allow_contactshow} eq 'Y') {
foreach my $type (keys %types) {
next unless $u->{$type};
$ret .= " <foaf:$types{$type}>" . LJ::exml($u->{$type}) . "</foaf:$types{$type}>\n";
}
}
# include a user's journal page and web site info
$ret .= " <foaf:weblog rdf:resource=\"" . LJ::journal_base($u) . "/\"/>\n";
if ($u->{url}) {
$ret .= " <foaf:homepage rdf:resource=\"" . LJ::eurl($u->{url});
$ret .= "\" dc:title=\"" . LJ::exml($u->{urlname}) . "\" />\n";
}
# interests, please!
# arrayref of interests rows: [ intid, intname, intcount ]
my $intu = LJ::get_interests($u);
foreach my $int (@$intu) {
LJ::text_out(\$int->[1]); # 1==interest
$ret .= " <foaf:interest dc:title=\"". LJ::exml($int->[1]) . "\" " .
"rdf:resource=\"$LJ::SITEROOT/interests.bml?int=" . LJ::eurl($int->[1]) . "\" />\n";
}
# check if the user has a "FOAF-knows" group
my $groups = LJ::get_friend_group($u->{userid}, { name => 'FOAF-knows' });
my $mask = $groups ? 1 << $groups->{groupnum} : 0;
# now information on who you know, limited to a certain maximum number of users
my $friends = LJ::get_friends($u->{userid}, $mask);
my @ids = keys %$friends;
@ids = splice(@ids, 0, $LJ::MAX_FOAF_FRIENDS) if @ids > $LJ::MAX_FOAF_FRIENDS;
# now load
my %users;
LJ::load_userids_multiple([ map { $_, \$users{$_} } @ids ], [$u]);
# iterate to create data structure
foreach my $friendid (@ids) {
next if $friendid == $u->{userid};
my $fu = $users{$friendid};
next if $fu->{statusvis} =~ /[DXS]/ || $fu->{journaltype} ne 'P';
$ret .= $comm ? " <foaf:member>\n" : " <foaf:knows>\n";
$ret .= " <foaf:Person>\n";
$ret .= " <foaf:nick>$fu->{'user'}</foaf:nick>\n";
$ret .= " <rdfs:seeAlso rdf:resource=\"" . LJ::journal_base($fu) ."/data/foaf\" />\n";
$ret .= " <foaf:weblog rdf:resource=\"" . LJ::journal_base($fu) . "/\"/>\n";
$ret .= " </foaf:Person>\n";
$ret .= $comm ? " </foaf:member>\n" : " </foaf:knows>\n";
}
# finish off the document
$ret .= $comm ? " </foaf:Group>\n" : " </foaf:Person>\n";
$ret .= "</rdf:RDF>\n";
return $ret;
}
1;