#!/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 # # 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 / (.*) # 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 = "

Invalid Filter

Either this reading filter doesn't exist or you are not authorized to view it. Try checking that you are logged in if you're sure you have the name right.

"; } $generate_iejunk = 1; } elsif ( $opts->{'suspendeduser'} ) { $status = "403 User suspended"; $html = "

Suspended User

The content at this URL is from a suspended user.

"; $generate_iejunk = 1; } elsif ( $opts->{'suspendedentry'} ) { $status = "403 Entry suspended"; $html = "

Suspended Entry

The entry at this URL is suspended. You cannot reply to it.

"; $generate_iejunk = 1; } elsif ( $opts->{'readonlyremote'} || $opts->{'readonlyjournal'} ) { $status = "403 Read-only user"; $html = "

Read-Only User

"; $html .= $opts->{'readonlyremote'} ? "

You are read-only. You cannot post comments.

" : "

This journal is read-only. You cannot comment in it.

"; $generate_iejunk = 1; } unless ($html) { $status = "500 Bad Template"; $html = "

Error

User $user has messed up their journal template definition.

"; $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 .= ( "\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 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!!$before_body_close!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;