426 lines
13 KiB
Perl
426 lines
13 KiB
Perl
|
|
#!/usr/bin/perl
|
||
|
|
#
|
||
|
|
# DW::Controller::Journal
|
||
|
|
#
|
||
|
|
# Shared journal rendering controller. Extracts the journal viewing pipeline
|
||
|
|
# from Apache::LiveJournal.pm so both Apache and Plack can render journals.
|
||
|
|
#
|
||
|
|
# Authors:
|
||
|
|
# Mark Smith <mark@dreamwidth.org>
|
||
|
|
#
|
||
|
|
# Copyright (c) 2025-2026 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::Controller::Journal;
|
||
|
|
|
||
|
|
use strict;
|
||
|
|
use v5.10;
|
||
|
|
use Log::Log4perl;
|
||
|
|
my $log = Log::Log4perl->get_logger(__PACKAGE__);
|
||
|
|
|
||
|
|
use DW::BML;
|
||
|
|
use DW::Request;
|
||
|
|
use DW::Routing;
|
||
|
|
use DW::Template;
|
||
|
|
use LJ::Entry;
|
||
|
|
use LJ::Links;
|
||
|
|
use LJ::PageStats;
|
||
|
|
|
||
|
|
# determine_view( $user, $uri, $args_wq, %GET )
|
||
|
|
#
|
||
|
|
# Pure URI parsing logic extracted from Apache::LiveJournal.pm's $determine_view.
|
||
|
|
# Maps a journal-relative URI to a view mode.
|
||
|
|
#
|
||
|
|
# Returns hashref { mode => ..., pathextra => ..., ljentry => ... }
|
||
|
|
# or a numeric HTTP status code (e.g. 404)
|
||
|
|
# or { redirect => $url, status => 301|302 }
|
||
|
|
# or undef if no mode could be determined.
|
||
|
|
sub determine_view {
|
||
|
|
my ( $class, $user, $uuri, $args_wq, %GET ) = @_;
|
||
|
|
|
||
|
|
my $mode;
|
||
|
|
my $pe;
|
||
|
|
my $ljentry;
|
||
|
|
|
||
|
|
# Favicon: not handled under Plack journal routing
|
||
|
|
return undef if $uuri eq "/favicon.ico";
|
||
|
|
|
||
|
|
# Redirect /tags -> /tag
|
||
|
|
if ( $uuri =~ m#^/tags(.*)# ) {
|
||
|
|
my $u = LJ::load_user($user) or return 404;
|
||
|
|
return { redirect => $u->journal_base . "/tag$1" };
|
||
|
|
}
|
||
|
|
|
||
|
|
# Redirect /calendar -> /archive
|
||
|
|
if ( $uuri =~ m#^/calendar(.*)# ) {
|
||
|
|
my $u = LJ::load_user($user) or return 404;
|
||
|
|
return { redirect => $u->journal_base . "/archive$1" };
|
||
|
|
}
|
||
|
|
|
||
|
|
# Entry by ditemid: /1234.html
|
||
|
|
if ( $uuri =~ m#^/(\d+)(\.html?)$#i ) {
|
||
|
|
return { redirect => "/$1.html$args_wq" }
|
||
|
|
unless $2 eq '.html';
|
||
|
|
|
||
|
|
my $u = LJ::load_user($user)
|
||
|
|
or return 404;
|
||
|
|
|
||
|
|
$ljentry = LJ::Entry->new( $u, ditemid => $1 );
|
||
|
|
if ( ( $GET{'mode'} // '' ) eq "reply" || $GET{'replyto'} || $GET{'edit'} ) {
|
||
|
|
$mode = "reply";
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
$mode = "entry";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Entry by slug: /2026/02/01/my-slug.html
|
||
|
|
elsif ( $uuri =~ m#^/(\d\d\d\d/\d\d/\d\d)/([a-z0-9_-]+)\.html$# ) {
|
||
|
|
my $u = LJ::load_user($user)
|
||
|
|
or return 404;
|
||
|
|
|
||
|
|
my $date = $1;
|
||
|
|
$ljentry = LJ::Entry->new( $u, slug => $2 );
|
||
|
|
if ( defined $ljentry ) {
|
||
|
|
my $dt = join( '/', split( '-', substr( $ljentry->eventtime_mysql, 0, 10 ) ) );
|
||
|
|
return 404 unless $dt eq $date;
|
||
|
|
}
|
||
|
|
|
||
|
|
if ( ( $GET{'mode'} // '' ) eq "reply" || $GET{'replyto'} || $GET{'edit'} ) {
|
||
|
|
$mode = "reply";
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
$mode = "entry";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Date views: /2026/, /2026/02/, /2026/02/01/
|
||
|
|
elsif ( $uuri =~ m#^/(\d\d\d\d)(?:/(\d\d)(?:/(\d\d))?)?(/?)$# ) {
|
||
|
|
my ( $year, $mon, $day, $slash ) = ( $1, $2, $3, $4 );
|
||
|
|
unless ($slash) {
|
||
|
|
my $u = LJ::load_user($user)
|
||
|
|
or return 404;
|
||
|
|
my $proper = $u->journal_base . "/$year";
|
||
|
|
$proper .= "/$mon" if defined $mon;
|
||
|
|
$proper .= "/$day" if defined $day;
|
||
|
|
$proper .= "/";
|
||
|
|
return { redirect => $proper };
|
||
|
|
}
|
||
|
|
|
||
|
|
$pe = $uuri;
|
||
|
|
|
||
|
|
if ( defined $day ) {
|
||
|
|
$mode = "day";
|
||
|
|
}
|
||
|
|
elsif ( defined $mon ) {
|
||
|
|
$mode = "month";
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
$mode = "archive";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Named views: /read, /tag, /archive, /network, etc.
|
||
|
|
elsif (
|
||
|
|
$uuri =~ m!
|
||
|
|
/([a-z\_]+)? # optional /<viewname>
|
||
|
|
(.*) # path extra
|
||
|
|
!x && ( ( $1 // '' ) eq "" || defined $LJ::viewinfo{ $1 // '' } )
|
||
|
|
)
|
||
|
|
{
|
||
|
|
( $mode, $pe ) = ( $1, $2 );
|
||
|
|
$mode ||= "" unless length $pe; # if no pathextra, then imply 'lastn'
|
||
|
|
|
||
|
|
# redirect old-style URLs
|
||
|
|
if ( $mode =~ /^day|calendar$/ && $pe =~ m!^/\d\d\d\d! ) {
|
||
|
|
my $newuri = $uuri;
|
||
|
|
$newuri =~ s!$mode/(\d\d\d\d)!$1!;
|
||
|
|
return { redirect => LJ::journal_base($user) . $newuri };
|
||
|
|
}
|
||
|
|
elsif ( $mode eq 'rss' ) {
|
||
|
|
return { redirect => LJ::journal_base($user) . "/data/rss$args_wq", status => 301 };
|
||
|
|
}
|
||
|
|
elsif ( $mode eq 'tag' ) {
|
||
|
|
return { redirect => LJ::journal_base($user) . "$uuri/" } unless $pe;
|
||
|
|
if ( $pe eq '/' ) {
|
||
|
|
$mode = 'tag';
|
||
|
|
$pe = undef;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
$mode = 'lastn';
|
||
|
|
$pe = "/tag$pe";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
elsif ( $mode eq 'security' ) {
|
||
|
|
return { redirect => LJ::journal_base($user) . "$uuri/" } unless $pe;
|
||
|
|
$mode = 'lastn';
|
||
|
|
$pe = "/security$pe";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
elsif ( $uuri eq "/robots.txt" ) {
|
||
|
|
$mode = "robots_txt";
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my $u = LJ::load_user($user)
|
||
|
|
or return 404;
|
||
|
|
|
||
|
|
# Unknown URI under journal context
|
||
|
|
return undef;
|
||
|
|
}
|
||
|
|
|
||
|
|
return undef unless defined $mode;
|
||
|
|
|
||
|
|
# Redirect renamed journals
|
||
|
|
my $u = LJ::load_user($user);
|
||
|
|
if ( $u && $u->is_redirect && $u->is_renamed ) {
|
||
|
|
my $renamedto = $u->prop('renamedto');
|
||
|
|
if ( $renamedto ne '' ) {
|
||
|
|
my $redirect_url =
|
||
|
|
( $renamedto =~ m!^https?://! )
|
||
|
|
? $renamedto
|
||
|
|
: LJ::journal_base($renamedto) . $uuri . $args_wq;
|
||
|
|
return { redirect => $redirect_url, status => 301 };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
mode => $mode,
|
||
|
|
pathextra => $pe,
|
||
|
|
ljentry => $ljentry,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
# render( user => $username, uri => $path, args => $query_string )
|
||
|
|
#
|
||
|
|
# Main entry point for journal rendering under Plack. Combines the logic from
|
||
|
|
# Apache::LiveJournal's $journal_view and journal_content subs.
|
||
|
|
#
|
||
|
|
# Returns the DW::Request response (via $r->OK, etc.) or undef if not handled.
|
||
|
|
sub render {
|
||
|
|
my ( $class, %params ) = @_;
|
||
|
|
|
||
|
|
my $orig_user = $params{user};
|
||
|
|
my $user = LJ::canonical_username($orig_user);
|
||
|
|
my $uri = $params{uri} || '/';
|
||
|
|
my $args = $params{args} || '';
|
||
|
|
my $args_wq = $args ? "?$args" : '';
|
||
|
|
|
||
|
|
my $r = DW::Request->get;
|
||
|
|
my $remote = LJ::get_remote();
|
||
|
|
|
||
|
|
my %GET = LJ::parse_args($args);
|
||
|
|
|
||
|
|
# Try DW::Routing first for user-context controllers
|
||
|
|
my $ret = DW::Routing->call( username => $user );
|
||
|
|
return $ret if defined $ret;
|
||
|
|
|
||
|
|
# Parse the URI into a view mode
|
||
|
|
my $view = $class->determine_view( $user, $uri, $args_wq, %GET );
|
||
|
|
|
||
|
|
# Not a journal URL we understand
|
||
|
|
return undef unless defined $view;
|
||
|
|
|
||
|
|
# Numeric return = HTTP status
|
||
|
|
return $view if !ref $view && $view =~ /^\d+$/;
|
||
|
|
|
||
|
|
# Redirect
|
||
|
|
if ( ref $view eq 'HASH' && $view->{redirect} ) {
|
||
|
|
my $status = $view->{status} || 302;
|
||
|
|
return $r->redirect( $view->{redirect}, $status );
|
||
|
|
}
|
||
|
|
|
||
|
|
my $mode = $view->{mode};
|
||
|
|
my $pe = $view->{pathextra};
|
||
|
|
my $ljentry = $view->{ljentry};
|
||
|
|
|
||
|
|
my $u = LJ::load_user($user);
|
||
|
|
|
||
|
|
# Handle special modes that redirect away
|
||
|
|
if ( $mode eq "info" ) {
|
||
|
|
$u or return 404;
|
||
|
|
my $m = ( $GET{mode} // '' ) eq 'full' ? '?mode=full' : '';
|
||
|
|
return $r->redirect( $u->profile_url . $m );
|
||
|
|
}
|
||
|
|
|
||
|
|
if ( $mode eq "profile" ) {
|
||
|
|
$r->note( '_journal', $user );
|
||
|
|
if ($u) {
|
||
|
|
$r->note( 'journalid', $u->{userid} );
|
||
|
|
}
|
||
|
|
return DW::Routing->call( uri => '/profile' );
|
||
|
|
}
|
||
|
|
|
||
|
|
if ( $mode eq "update" ) {
|
||
|
|
$u or return 404;
|
||
|
|
return $r->redirect( "$LJ::SITEROOT/update.bml?usejournal=" . $u->{'user'} );
|
||
|
|
}
|
||
|
|
|
||
|
|
# Robots.txt
|
||
|
|
if ( $mode eq "robots_txt" ) {
|
||
|
|
$u or return 404;
|
||
|
|
$u->preload_props( "opt_blockrobots", "adult_content" );
|
||
|
|
$r->content_type("text/plain");
|
||
|
|
my @extra = LJ::Hooks::run_hook( "robots_txt_extra", $u ), ();
|
||
|
|
$r->print($_) for @extra;
|
||
|
|
$r->print("User-Agent: *\n");
|
||
|
|
if ( $u->should_block_robots ) {
|
||
|
|
$r->print("Disallow: /\n");
|
||
|
|
}
|
||
|
|
return $r->OK;
|
||
|
|
}
|
||
|
|
|
||
|
|
# Data handlers (RSS, Atom, FOAF, etc.)
|
||
|
|
if ( $mode eq "data" && $pe =~ m!^/(\w+)(/.*)?! ) {
|
||
|
|
my ( $data_mode, $data_path ) = ( $1, $2 );
|
||
|
|
if ( my $handler = LJ::Hooks::run_hook( "data_handler:$data_mode", $user, $data_path ) ) {
|
||
|
|
|
||
|
|
# Data handlers are coderefs that expect an Apache request object.
|
||
|
|
# Create an adapter and call it directly.
|
||
|
|
my $adapter = DW::BML::RequestAdapter->new($r);
|
||
|
|
$handler->($adapter);
|
||
|
|
return $r->OK;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Main journal rendering via LJ::make_journal
|
||
|
|
my $handle_with_siteviews = 0;
|
||
|
|
my %headers;
|
||
|
|
my $adapter = DW::BML::RequestAdapter->new($r);
|
||
|
|
|
||
|
|
my $opts = {
|
||
|
|
'r' => $adapter,
|
||
|
|
'headers' => \%headers,
|
||
|
|
'args' => $args,
|
||
|
|
'vhost' => 'users',
|
||
|
|
'pathextra' => $pe,
|
||
|
|
'header' => {
|
||
|
|
'If-Modified-Since' => $r->header_in("If-Modified-Since") // '',
|
||
|
|
},
|
||
|
|
'handle_with_siteviews_ref' => \$handle_with_siteviews,
|
||
|
|
'siteviews_extra_content' => {},
|
||
|
|
'ljentry' => $ljentry,
|
||
|
|
};
|
||
|
|
|
||
|
|
$r->note( 'view', $mode );
|
||
|
|
|
||
|
|
my $html = LJ::make_journal( $user, $mode, $remote, $opts );
|
||
|
|
|
||
|
|
# After-journal hooks
|
||
|
|
LJ::Hooks::run_hooks( "after_journal_content_created", $opts, \$html )
|
||
|
|
unless $handle_with_siteviews;
|
||
|
|
|
||
|
|
# Internal redirect
|
||
|
|
if ( $opts->{internal_redir} ) {
|
||
|
|
my $int_redir = DW::Routing->call( uri => $opts->{internal_redir} );
|
||
|
|
if ( defined $int_redir ) {
|
||
|
|
LJ::start_request();
|
||
|
|
return $int_redir;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# External redirect
|
||
|
|
return $r->redirect( $opts->{'redir'} ) if $opts->{'redir'};
|
||
|
|
return $opts->{'handler_return'} if defined $opts->{'handler_return'};
|
||
|
|
|
||
|
|
# Siteviews handling
|
||
|
|
return DW::Template->render_string( $html, $opts->{siteviews_extra_content} )
|
||
|
|
if $handle_with_siteviews && $html;
|
||
|
|
|
||
|
|
# Set status
|
||
|
|
my $status = $opts->{'status'} || "200 OK";
|
||
|
|
$opts->{'contenttype'} ||= "text/html";
|
||
|
|
if ( $opts->{'contenttype'} =~ m!^text/!
|
||
|
|
&& $opts->{'contenttype'} !~ /charset=/ )
|
||
|
|
{
|
||
|
|
$opts->{'contenttype'} .= "; charset=utf-8";
|
||
|
|
}
|
||
|
|
|
||
|
|
my $generate_iejunk = 0;
|
||
|
|
|
||
|
|
if ( $opts->{'badargs'} ) {
|
||
|
|
return 404;
|
||
|
|
}
|
||
|
|
elsif ( $opts->{'badfriendgroup'} ) {
|
||
|
|
if ( $remote && $remote->{'user'} eq $user ) {
|
||
|
|
return 404;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
$status = "403 Forbidden";
|
||
|
|
$html =
|
||
|
|
"<h1>Invalid Filter</h1><p>Either this reading filter doesn't exist or you are not authorized to view it. Try <a href='$LJ::SITEROOT/login'>checking that you are logged in</a> if you're sure you have the name right.</p>";
|
||
|
|
}
|
||
|
|
$generate_iejunk = 1;
|
||
|
|
}
|
||
|
|
elsif ( $opts->{'suspendeduser'} ) {
|
||
|
|
$status = "403 User suspended";
|
||
|
|
$html = "<h1>Suspended User</h1><p>The content at this URL is from a suspended user.</p>";
|
||
|
|
$generate_iejunk = 1;
|
||
|
|
}
|
||
|
|
elsif ( $opts->{'suspendedentry'} ) {
|
||
|
|
$status = "403 Entry suspended";
|
||
|
|
$html =
|
||
|
|
"<h1>Suspended Entry</h1><p>The entry at this URL is suspended. You cannot reply to it.</p>";
|
||
|
|
$generate_iejunk = 1;
|
||
|
|
}
|
||
|
|
elsif ( $opts->{'readonlyremote'} || $opts->{'readonlyjournal'} ) {
|
||
|
|
$status = "403 Read-only user";
|
||
|
|
$html = "<h1>Read-Only User</h1>";
|
||
|
|
$html .=
|
||
|
|
$opts->{'readonlyremote'}
|
||
|
|
? "<p>You are read-only. You cannot post comments.</p>"
|
||
|
|
: "<p>This journal is read-only. You cannot comment in it.</p>";
|
||
|
|
$generate_iejunk = 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
unless ($html) {
|
||
|
|
$status = "500 Bad Template";
|
||
|
|
$html =
|
||
|
|
"<h1>Error</h1><p>User <b>$user</b> has messed up their journal template definition.</p>";
|
||
|
|
$generate_iejunk = 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
$r->status( $status =~ m/^(\d+)/ && $1 );
|
||
|
|
|
||
|
|
# Set response headers
|
||
|
|
foreach my $hname ( keys %headers ) {
|
||
|
|
if ( ref( $headers{$hname} ) && ref( $headers{$hname} ) eq "ARRAY" ) {
|
||
|
|
foreach ( @{ $headers{$hname} } ) {
|
||
|
|
$r->header_out( $hname, $_ );
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
$r->header_out( $hname, $headers{$hname} );
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$r->content_type( $opts->{'contenttype'} );
|
||
|
|
$r->header_out( "Cache-Control", "private, proxy-revalidate" );
|
||
|
|
|
||
|
|
$html .= ( "<!-- xxxxxxxxxxxxxxxxxxxxxxxxxxxx -->\n" x 100 ) if $generate_iejunk;
|
||
|
|
|
||
|
|
# Parse the page content for any temporary matches
|
||
|
|
if ( my $cb = $LJ::TEMP_PARSE_MAKE_JOURNAL ) {
|
||
|
|
$cb->( \$html );
|
||
|
|
}
|
||
|
|
|
||
|
|
# Add stuff before </body>
|
||
|
|
my $before_body_close = "";
|
||
|
|
LJ::Hooks::run_hooks( "insert_html_before_body_close", \$before_body_close );
|
||
|
|
LJ::Hooks::run_hooks( "insert_html_before_journalctx_body_close", \$before_body_close );
|
||
|
|
$before_body_close .= LJ::PageStats->new->render('journal');
|
||
|
|
$html =~ s!</body>!$before_body_close</body>!i if $before_body_close;
|
||
|
|
|
||
|
|
# No manual gzip — let Plack::Middleware::Deflater handle it
|
||
|
|
|
||
|
|
$r->header_out( "Content-length", length($html) );
|
||
|
|
$r->print($html) unless $r->method eq 'HEAD';
|
||
|
|
|
||
|
|
return $r->OK;
|
||
|
|
}
|
||
|
|
|
||
|
|
1;
|