CMS.pm 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. # vi: set tabstop=4 expandtab shiftwidth=4:
  2. ##############################################################################
  3. package CMS;
  4. =pod
  5. =head1 NAME
  6. CMS - Main FastCGI handler serving CMS pages from a CMS directory
  7. =head1 DESCRIPTION
  8. Full featured page generator.
  9. =cut
  10. use strict;
  11. use warnings;
  12. use Carp;
  13. use parent 'CMS::Handler';
  14. use CMS::Session;
  15. use CMS::Trace qw(funcname);
  16. use CMS::FileHelper qw(getNewestFileDate getDirectoryEntries);
  17. use Authen::Htpasswd;
  18. use File::Path qw(make_path);
  19. use File::Spec;
  20. use Sys::Hostname;
  21. use Sys::Syslog qw(:macros :standard);
  22. use HTML::Template;
  23. ##############################################################################
  24. our $VERSION = '0.01';
  25. =head1 CLASS INTERFACE
  26. =head2 Constructor
  27. =over
  28. =item new(...)
  29. Create a new instance of the class.
  30. Additional parameters to the ones accepted by the base class are:
  31. =over
  32. =item * B<CMS_ROOT>:
  33. Root directory of the CMS.
  34. =item * B<CONFIG>:
  35. A hash with config parameters. Is usually filled via the CMS::Config
  36. class via the C<config.yaml> file in the C<CMS_ROOT> directory.
  37. =back
  38. =back
  39. =cut
  40. sub new {
  41. my $class = shift;
  42. my $params = shift;
  43. syslog(LOG_DEBUG, funcname());
  44. my $self = $class->SUPER::new($params);
  45. $self->{CONFIG} = $params->{CONFIG} || { };
  46. $self->{REDIRECT} = undef; # URL of redirect if enabled
  47. $self->{PAGE_URI} = undef;
  48. $self->{PAGE_LANG} = '';
  49. $self->{CMS_ROOT} = $params->{CMS_ROOT} || '/var/www/cms';
  50. $self->{CHROOT} = $params->{CHROOT};
  51. # Check the config and fill in missing defaults
  52. my $full_path = $self->{CHROOT} || '';
  53. $full_path .= $self->{CMS_ROOT};
  54. die "CMS_ROOT does not exist\n" unless (-d $full_path);
  55. if (!$self->{CONFIG}->{defaults}) {
  56. $self->{CONFIG}->{defaults} = { };
  57. }
  58. if (!$self->{CONFIG}->{defaults}->{language}) {
  59. $self->{CONFIG}->{defaults}->{language} = 'en';
  60. }
  61. if (!$self->{CONFIG}->{defaults}->{page}) {
  62. $self->{CONFIG}->{defaults}->{page} = 'home';
  63. }
  64. if (!$self->{CONFIG}->{hostname}->{plain}) {
  65. $self->{CONFIG}->{hostname}->{plain} = hostname();
  66. }
  67. if (!$self->{CONFIG}->{hostname}->{ssl}) {
  68. $self->{CONFIG}->{hostname}->{ssl}
  69. = $self->{CONFIG}->{hostname}->{plain};
  70. }
  71. if (!$self->{CONFIG}->{url}->{images}) {
  72. $self->{CONFIG}->{url}->{images} = '/images/';
  73. }
  74. if (!$self->{CONFIG}->{session}) {
  75. $self->{CONFIG}->{session} = { };
  76. }
  77. if (!$self->{CONFIG}->{session}->{path}) {
  78. $self->{CONFIG}->{session}->{path} = '/tmp/sessions';
  79. }
  80. if (!$self->{CONFIG}->{session}->{cookiedomain}) {
  81. $self->{CONFIG}->{session}->{cookiedomain}
  82. = '.' . $self->{CONFIG}->{hostname}->{ssl};
  83. }
  84. if (!$self->{CONFIG}->{userdb}) {
  85. $self->{CONFIG}->{userdb} = $self->{CMS_ROOT} . '/user.db';
  86. }
  87. elsif ($self->{CONFIG}->{userdb} !~ /^\//x) {
  88. # Relative path, prepend it with the CMS_ROOT
  89. $self->{CONFIG}->{userdb} = $self->{CMS_ROOT} . '/'
  90. . $self->{CONFIG}->{userdb};
  91. }
  92. $self->{CONTENT_DIR} = $self->{CMS_ROOT} . '/content/';
  93. $self->{TEMPLATE_DIR} = $self->{CMS_ROOT} . '/templates/';
  94. bless($self, $class);
  95. return $self;
  96. }
  97. =head2 Member Functions
  98. =over
  99. =item handler($req, $params)
  100. Request handler, will setup the base class by calling the SUPER handler
  101. function. Parses optional parameters from POST and GET requests and tries
  102. to build a page from the B<CMS_ROOT> directory and the query path.
  103. =cut
  104. sub handler {
  105. my $self = shift;
  106. my $req = shift;
  107. my $params = shift;
  108. syslog(LOG_DEBUG, funcname());
  109. # Setup in- and outputs via the SUPER class if handling a FCGI request
  110. $self->SUPER::handler($req, $params) if ($req);
  111. $self->{HTTPS} = $ENV{'HTTPS'};
  112. my $fail = not eval {
  113. $self->parse_params();
  114. $self->fetch();
  115. return 1;
  116. };
  117. if ($fail) {
  118. syslog(LOG_ERR, 'CMS::handler(): Unable to fetch page. ' . $@);
  119. # XXX Render error page
  120. $self->{STATUS} = '500 Internal Server Error';
  121. $self->{BODY} = '$@';
  122. $self->add_header('Content-type', 'plain/text');
  123. }
  124. return;
  125. }
  126. =item fetch()
  127. Fetches the page.
  128. =cut
  129. sub fetch {
  130. my $self = shift;
  131. syslog(LOG_DEBUG, funcname());
  132. # Set defaults
  133. $self->{PAGE_LANG} = $self->{CONFIG}->{defaults}->{language};
  134. # Retrieve the last part of the URI, to translate the path to the CMS
  135. # structure
  136. my $page = $ENV{'DOCUMENT_URI'} || '/index.html';
  137. my $req_uri = $page;
  138. $req_uri =~ s/\/[^\/]+$//x;
  139. $self->{REQUEST_PATH} = $req_uri;
  140. $page =~ s/^.*\///x;
  141. $self->{PAGE_URI} = $page;
  142. # Show the default page if the index.html page is requested
  143. if ($page eq 'index.html') {
  144. $page = $self->{CONFIG}->{defaults}->{page}
  145. . '_' . $self->{CONFIG}->{defaults}->{language} . '.html';
  146. $self->{PAGE_URI} = $page; # So that we don't generate a redirect
  147. }
  148. # The language is embedded in the last part of the page e.g. "_en.html"
  149. my $lang = $page;
  150. if ($lang =~ s/^.*_(..)\.html$/$1/x) {
  151. $self->{PAGE_LANG} = $lang;
  152. }
  153. # Split off the part with the language and the suffix to get the
  154. # page name
  155. $page =~ s/(_..)?\.html$//x;
  156. $self->{PAGE} = $page;
  157. # Try to set the language, or set the default language if nothing matches
  158. $self->set_language();
  159. # Create the response body from the template
  160. $self->create_document();
  161. return;
  162. }
  163. =item set_language()
  164. The function checks the previously set C<$self-E<gt>{PAGE_LANG}> member
  165. variable for an existing directory if defined. If it is not defined or
  166. the directory is not set the HTTP_ACCEPT_LANGUAGE header will be used
  167. to find an acceptable language.
  168. =cut
  169. sub set_language {
  170. my $self = shift;
  171. syslog(LOG_DEBUG, funcname());
  172. my $lang = $self->{PAGE_LANG};
  173. $lang = undef if (! -d $self->{CONTENT_DIR} . $lang);
  174. if (not defined $lang) {
  175. my $accept_lang = $ENV{'HTTP_ACCEPT_LANGUAGE'};
  176. if ($accept_lang) {
  177. my @languages = split ',', $accept_lang;
  178. foreach (@languages) {
  179. s/;.*//gx;
  180. s/-.*//gx;
  181. if (-d $self->{CONTENT_DIR} . $_) {
  182. $lang = $_;
  183. last;
  184. }
  185. }
  186. }
  187. $lang = $self->{CONFIG}->{defaults}->{language} unless $lang;
  188. }
  189. # Now we should have a language, die if the language directory does
  190. # not exist
  191. die 'No content found: ' . "$self->{CONTENT_DIR}\n"
  192. unless (-d $self->{CONTENT_DIR} . $lang);
  193. $self->{PAGE_LANG} = $lang;
  194. return;
  195. }
  196. =item create_document()
  197. Puts everything together.
  198. At the end a filled in page body is stored in C<$self-E<gt>{BODY}>.
  199. =cut
  200. sub create_document {
  201. my $self = shift;
  202. syslog(LOG_DEBUG, funcname());
  203. # Read all available language directories
  204. my @languages = sort (getDirectoryEntries($self->{CONTENT_DIR}));
  205. # Check if the page exists, if not set the status to 404 and return
  206. my $page_dir = $self->{CONTENT_DIR} . $self->{PAGE_LANG} . '/'
  207. . $self->{PAGE};
  208. my $page_uri = $self->{PAGE} . '_' . $self->{PAGE_LANG} . '.html';
  209. if ((! -d $page_dir) || ($page_uri ne $self->{PAGE_URI})) {
  210. $self->{STATUS} = '404 Not Found';
  211. $self->{BODY} = '';
  212. return;
  213. }
  214. # Redirect if we require SSL view of the page
  215. my $need_ssl_file = $page_dir . '/SSL';
  216. if (-e $need_ssl_file && !($self->{HTTPS})) {
  217. my $hostname_ssl = $self->{CONFIG}->{hostname}->{ssl};
  218. my $path = $self->{REQUEST_PATH};
  219. $path =~ s/^\/+//x;
  220. $self->{REDIRECT} = 'https://' . $hostname_ssl . '/'
  221. . $path . '/' . $page_uri;
  222. return;
  223. }
  224. # Create the content array
  225. my @topics = sort
  226. (getDirectoryEntries($self->{CONTENT_DIR} . $self->{PAGE_LANG}));
  227. # Create links to other languages
  228. my @languagelinks;
  229. foreach (@languages) {
  230. if ((-e $self->{CONTENT_DIR} . $_)
  231. && ($_ ne '.') && ($_ ne '..')) {
  232. my %language_data;
  233. my $link = '<a href="' . $self->{REQUEST_PATH} . '/'
  234. . $self->{PAGE} . '_' . $_ . '.html"><img src="'
  235. . $self->{CONFIG}->{url}->{images} . 'flag_' . $_ . '.png"'
  236. . ' alt="' . $_ . '"/></a>';
  237. $language_data{LANGUAGE_LINK} = $link;
  238. push @languagelinks, \%language_data;
  239. }
  240. }
  241. # Read the template for the page
  242. my $template = HTML::Template->new(
  243. filename => 'page.tmpl',
  244. path => [ $self->{TEMPLATE_DIR} ],
  245. cache => 1,
  246. );
  247. croak 'Unable to load template file from directory ' . $self->{TEMPLATE_DIR}
  248. unless $template;
  249. # Fill in the language variable
  250. $template->param(LANGUAGE => $self->{PAGE_LANG});
  251. # Fetch the session, if there is one
  252. $self->fetch_session();
  253. # Remove the session if we are to log out
  254. my $action = $self->{PARAMS}->{action};
  255. if ($action && ($action eq 'logout')) {
  256. $self->destroy_session();
  257. }
  258. # Read all the CMS files that will create the page
  259. my $title_file = $page_dir . '/TITLE';
  260. if (-e $title_file) {
  261. $template->param(TITLE => $self->read_file($title_file));
  262. }
  263. my $descr_file = $page_dir . '/DESCR';
  264. if (-e $descr_file) {
  265. $template->param(DESCR => $self->read_file($descr_file));
  266. }
  267. my $style_file = $page_dir . '/STYLE';
  268. if (-e $style_file) {
  269. $template->param(STYLE => $self->read_file($style_file));
  270. }
  271. my $script_file = $page_dir . '/SCRIPT';
  272. if (-e $script_file) {
  273. $template->param(SCRIPT => $self->read_file($script_file));
  274. }
  275. my $login_file = $page_dir . '/LOGIN';
  276. my $content_file = $page_dir . '/CONTENT';
  277. # Replace the content with the login page, so that we can authenticate
  278. # the user if the page requires a login and we have not authenticated
  279. # via the session
  280. if (-e $login_file) {
  281. # Do the session and log on magic
  282. $self->create_session() unless $self->{SESSION};
  283. my $session = $self->{SESSION};
  284. my $userdb = Authen::Htpasswd->new($self->{CONFIG}->{userdb});
  285. # Get the username and password from the POST or GET data
  286. my $username = $self->{PARAMS}->{username} || '';
  287. my $password = $self->{PARAMS}->{password} || '';
  288. if ($username eq '') {
  289. if ($session && $session->get('loggedin')) {
  290. if ($session->get('loggedin') != 1) {
  291. $session->set('loggedin', 0);
  292. $content_file = $login_file;
  293. }
  294. }
  295. else {
  296. $content_file = $login_file;
  297. }
  298. }
  299. elsif (! $userdb->lookup_user($username)) {
  300. $content_file = $login_file;
  301. $session->set('loggedin', 0);
  302. }
  303. elsif (! $userdb->check_user_password($username, $password)) {
  304. $content_file = $login_file;
  305. $session->set('loggedin', 0);
  306. }
  307. else {
  308. $session->set('username', $username);
  309. $session->set('loggedin', 1);
  310. }
  311. }
  312. if (-e $content_file) {
  313. # Parse the content file as a template, so that we can include files
  314. my $content = HTML::Template->new(
  315. filename => $content_file,
  316. cache => 0,
  317. );
  318. die 'Unable to include template file "' . $content_file . '"' . "\n"
  319. unless $content;
  320. if ($content->query(name => 'CURRENT_PAGE')) {
  321. $content->param(CURRENT_PAGE => $page_uri);
  322. }
  323. $template->param(CONTENT => $content->output());
  324. }
  325. else {
  326. die 'No content file available for page "' . $self->{PAGE}
  327. . '" and language "' . $self->{PAGE_LANG} . '"' . "\n";
  328. }
  329. # Create links
  330. my @templinks = $self->create_links(\@topics, 1);
  331. my @links = sort { $a->{NR} <=> $b->{NR} } @templinks;
  332. $template->param(LINK_LOOP => \@links);
  333. $template->param(LANGUAGE_LINKS => \@languagelinks);
  334. # Add helpful header
  335. $self->add_header('Content-Language', $self->{PAGE_LANG})
  336. if $self->{PAGE_LANG};
  337. # Create the (X)HTML payload
  338. $self->{BODY} = $template->output();
  339. my $lastchange = getNewestFileDate($page_dir);
  340. $self->add_header('Last-Modified', $lastchange) if $lastchange;
  341. return;
  342. }
  343. =item render()
  344. Page renderer. Will set the header for the optional cookie if available and
  345. either redirect if C<$self-E<gt>{REDIRECT}> is defined or output the page
  346. from C<$self-E<gt>{BODY}>.
  347. =cut
  348. sub render {
  349. my $self = shift;
  350. syslog(LOG_DEBUG, funcname());
  351. # Handle Cookies
  352. $self->set_session_cookie();
  353. # Handle Redirects
  354. return $self->redirect($self->{REDIRECT}) if ($self->{REDIRECT});
  355. # Handle MSIE document type
  356. my $ua = $ENV{'HTTP_USER_AGENT'};
  357. if ($ua && ($ua !~ /MSIE/)) {
  358. $self->add_header('Content-type', 'application/xhtml+xml');
  359. }
  360. else {
  361. $self->add_header('Content-type', 'text/html');
  362. }
  363. return $self->SUPER::render();
  364. }
  365. =item read_file($filename)
  366. Reads a whole file into a scalar and returns the scalar. If opening the file
  367. fails the function will die.
  368. =cut
  369. sub read_file {
  370. my $self = shift;
  371. my $filename = shift;
  372. syslog(LOG_DEBUG, funcname());
  373. my $content;
  374. if (open(my $fh, '<', $filename)) {
  375. local $/ = undef;
  376. $content = <$fh>;
  377. close($fh);
  378. }
  379. else {
  380. die('Unable to open file "' . $filename . '": ' . $! . "\n");
  381. }
  382. return $content;
  383. }
  384. =item create_links($topics, $sublevel)
  385. Creates a link structure from the content directory layout
  386. =cut
  387. sub create_links {
  388. my $self = shift;
  389. my $topics = shift;
  390. my $sublevel = shift;
  391. syslog(LOG_DEBUG, funcname());
  392. my $lang = $self->{PAGE_LANG};
  393. my $page = $self->{PAGE};
  394. my (@templinks, @subpages);
  395. while (@$topics) {
  396. my %link_data;
  397. my $topic = shift @$topics;
  398. # Create the filenames from the topic
  399. my $topic_dir = $self->{CONTENT_DIR} . $lang . '/' . $topic;
  400. my $link_file = $topic_dir . '/LINK';
  401. my $sort_file = $topic_dir . '/SORT';
  402. my $sub_file = $topic_dir . '/SUB';
  403. my $ssl_file = $topic_dir . '/SSL';
  404. if ((-e $link_file) && (-e $sort_file)) {
  405. # Read the title and the sort order from the files
  406. my $title = $self->read_file($link_file);
  407. my $nr = $self->read_file($sort_file);
  408. # Check if this link is a sub link to another
  409. $link_data{SUB} = (-e $sub_file);
  410. # Create the link
  411. my $link = $self->{REQUEST_PATH} . '/' . $topic . '_' . $lang
  412. . '.html';
  413. if ($self->{HTTPS} || (-e $ssl_file)) {
  414. $link =~ s/^\///x;
  415. $link = 'https://' . $self->{CONFIG}->{hostname}->{ssl} . '/'
  416. . $link;
  417. }
  418. $link_data{LINK} = '<a href="' . $link . '">' . $title . '</a>';
  419. $link_data{JSLINK} = 'onclick="javascript:location.replace(\''
  420. . $link . '\')"';
  421. $link_data{NR} = $nr;
  422. if ($topic eq $page) {
  423. # Mark the page as selected
  424. $link_data{SELECTED} = 1;
  425. # If selected, show all sub pages
  426. my $subpagesdir = $topic_dir . '/SUBPAGES';
  427. if (-d $subpagesdir) {
  428. if (opendir(my $dh, $subpagesdir)) {
  429. @subpages = sort readdir($dh);
  430. closedir($dh);
  431. }
  432. else {
  433. syslog(LOG_ERR, 'CMS::create_links(): Unable to open '
  434. . 'SUBPAGES directory: ' . $subpagesdir);
  435. }
  436. push @templinks,
  437. $self->create_links(\@subpages, $sublevel + 1);
  438. }
  439. }
  440. else {
  441. $link_data{SELECTED} = 0;
  442. }
  443. push @templinks, \%link_data;
  444. }
  445. }
  446. # Remove sublinks, that are not in the selection scope
  447. my @newlinks = ();
  448. my $lastrootmenu = 0;
  449. my $submenu_selected = undef;
  450. my $i = 0;
  451. while (defined $templinks[$i]) {
  452. if (! defined $templinks[$i]->{SUB}) {
  453. push @newlinks, $templinks[$i];
  454. $lastrootmenu = $i;
  455. $submenu_selected = $templinks[$i]->{SELECTED};
  456. }
  457. else {
  458. if ($submenu_selected) {
  459. push @newlinks, $templinks[$i];
  460. }
  461. else {
  462. # Check if the submenu is selected
  463. if ($templinks[$i]->{SELECTED}) {
  464. # Copy from $submenu_selected to here
  465. while ($lastrootmenu < $i) {
  466. push @newlinks, $templinks[++$lastrootmenu];
  467. }
  468. # Mark as selected
  469. $submenu_selected = 1;
  470. }
  471. }
  472. }
  473. $i++;
  474. }
  475. # Add a logout link if we have a valid login session
  476. if ($self->{SESSION} && $self->{SESSION}->get('loggedin')
  477. && ($self->{SESSION}->get('loggedin') == 1) && ($sublevel == 1)) {
  478. my $logout_link = $self->{PAGE_URI} . '?action=logout';
  479. push @newlinks, {
  480. SUB => 0,
  481. SELECTED => 0,
  482. LINK => '<a href="' . $logout_link . '">Logout</a>',
  483. JSLINK => 'onclick="javascript:location.replace(\''
  484. . $logout_link . '\')"',
  485. NR => 1000,
  486. };
  487. }
  488. return @newlinks;
  489. }
  490. 1;
  491. __END__
  492. =back