CMS.pm 17 KB

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