]> git.llucax.com Git - software/blitiri.git/blob - blitiri.cgi
Only handle comments when they are enabled
[software/blitiri.git] / blitiri.cgi
1 #!/usr/bin/env python
2 #coding: utf8
3
4 # blitiri - A single-file blog engine.
5 # Alberto Bertogli (albertito@gmail.com)
6
7 #
8 # Configuration section
9 #
10 # You can edit these values, or create a file named "config.py" and put them
11 # there to make updating easier. The ones in config.py take precedence.
12 #
13
14 # Directory where entries are stored
15 data_path = "/tmp/blog/data"
16
17 # Are comments allowed? (if False, comments_path option is not used)
18 enable_comments = False
19
20 # Directory where comments are stored (must be writeable by the web server)
21 comments_path = "/tmp/blog/comments"
22
23 # Path where templates are stored. Use an empty string for the built-in
24 # default templates. If they're not found, the built-in ones will be used.
25 templates_path = "/tmp/blog/templates"
26
27 # Path where the cache is stored (must be writeable by the web server);
28 # set to None to disable. When enabled, you must take care of cleaning it up
29 # every once in a while.
30 #cache_path = "/tmp/blog/cache"
31 cache_path = None
32
33 # URL to the blog, including the name. Can be a full URL or just the path.
34 blog_url = "/blog/blitiri.cgi"
35
36 # Style sheet (CSS) URL. Can be relative or absolute. To use the built-in
37 # default, set it to blog_url + "/style".
38 css_url = blog_url + "/style"
39
40 # Blog title
41 title = "I don't like blogs"
42
43 # Default author
44 author = "Hartmut Kegan"
45
46 # Article encoding
47 encoding = "utf8"
48
49 # Captcha method to use. At the moment only "title" is supported, but if you
50 # are keen with Python you can provide your own captcha implementation, see
51 # below for details.
52 captcha_method = "title"
53
54
55 #
56 # End of configuration
57 # DO *NOT* EDIT ANYTHING PAST HERE
58 #
59
60
61 import sys
62 import os
63 import errno
64 import shutil
65 import time
66 import datetime
67 import calendar
68 import zlib
69 import urllib
70 import cgi
71 from docutils.core import publish_parts
72 from docutils.utils import SystemMessage
73
74 # Before importing the config, add our cwd to the Python path
75 sys.path.append(os.getcwd())
76
77 # Load the config file, if there is one
78 try:
79         from config import *
80 except:
81         pass
82
83
84 # Pimp *_path config variables to support relative paths
85 data_path = os.path.realpath(data_path)
86 templates_path = os.path.realpath(templates_path)
87
88
89 #
90 # Captcha classes
91 #
92 # They must follow the interface described below.
93 #
94 # Constructor:
95 #       Captcha(article) -> constructor, takes an article[1] as argument
96 # Attributes:
97 #       puzzle -> a string with the puzzle the user must solve to prove he is
98 #                 not a bot (can be raw HTML)
99 #       help -> a string with extra instructions, shown only when the user
100 #               failed to solve the puzzle
101 # Methods:
102 #       validate(form_data) -> based on the form data[2],  returns True if
103 #                              the user has solved the puzzle uccessfully
104 #                              (False otherwise).
105 #
106 # Note you must ensure that the puzzle attribute and validate() method can
107 # "communicate" because they are executed in different requests. You can pass a
108 # cookie or just calculate the answer based on the article's data, for example.
109 #
110 # [1] article is an object with all the article's information:
111 #       path -> string
112 #       created -> datetime
113 #       updated -> datetime
114 #       uuid -> string (unique ID)
115 #       title -> string
116 #       author -> string
117 #       tags -> list of strings
118 #       raw_contents -> string in rst format
119 #       comments -> list of Comment objects (not too relevant here)
120 # [2] form_data is an object with the form fields (all strings):
121 #       author, author_error
122 #       link, link_error
123 #       catpcha, captcha_error
124 #       body, body_error
125 #       action, method
126
127 class TitleCaptcha (object):
128         "Captcha that uses the article's title for the puzzle"
129         def __init__(self, article):
130                 self.article = article
131                 words = article.title.split()
132                 self.nword = hash(article.title) % len(words) % 5
133                 self.answer = words[self.nword]
134                 self.help = 'gotcha, damn spam bot!'
135
136         @property
137         def puzzle(self):
138                 nword = self.nword + 1
139                 if nword == 1:
140                         n = '1st'
141                 elif nword == 2:
142                         n = '2nd'
143                 elif nword == 3:
144                         n = '3rd'
145                 else:
146                         n = str(nword) + 'th'
147                 return "enter the %s word of the article's title" % n
148
149         def validate(self, form_data):
150                 if form_data.captcha.lower() == self.answer.lower():
151                         return True
152                 return False
153
154 known_captcha_methods = {
155         'title': TitleCaptcha,
156 }
157
158 # If the configured captcha method was a known string, replace it by the
159 # matching class; otherwise assume it's already a class and leave it
160 # alone. This way the user can either use one of our methods, or provide one
161 # of his/her own.
162 if captcha_method in known_captcha_methods:
163         captcha_method = known_captcha_methods[captcha_method]
164
165
166 # Default template
167
168 default_main_header = """\
169 <?xml version="1.0" encoding="utf-8"?>
170 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
171           "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
172
173 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
174 <head>
175 <link rel="alternate" title="%(title)s" href="%(fullurl)s/atom"
176         type="application/atom+xml" />
177 <link href="%(css_url)s" rel="stylesheet" type="text/css" />
178 <title>%(title)s</title>
179 </head>
180
181 <body>
182
183 <h1><a href="%(url)s">%(title)s</a></h1>
184
185 <div class="content">
186 """
187
188 default_main_footer = """
189 </div>
190 <div class="footer">
191   %(showyear)s: %(monthlinks)s<br/>
192   years: %(yearlinks)s<br/>
193   subscribe: <a href="%(url)s/atom">atom</a><br/>
194   views: <a href="%(url)s/">blog</a> <a href="%(url)s/list">list</a><br/>
195 </div>
196
197 </body>
198 </html>
199 """
200
201 default_article_header = """
202 <div class="article">
203 <h2><a href="%(url)s/post/%(uuid)s">%(arttitle)s</a></h2>
204 <span class="artinfo">
205   by %(author)s on <span class="date">
206
207 <a class="date" href="%(url)s/%(cyear)d/">%(cyear)04d</a>-\
208 <a class="date" href="%(url)s/%(cyear)d/%(cmonth)d/">%(cmonth)02d</a>-\
209 <a class="date" href="%(url)s/%(cyear)d/%(cmonth)d/%(cday)d/">%(cday)02d</a>\
210     %(chour)02d:%(cminute)02d</span>
211   (updated on <span class="date">
212 <a class="date" href="%(url)s/%(uyear)d/">%(uyear)04d</a>-\
213 <a class="date" href="%(url)s/%(uyear)d/%(umonth)d/">%(umonth)02d</a>-\
214 <a class="date" href="%(url)s/%(uyear)d/%(umonth)d/%(uday)d/">%(uday)02d</a>\
215     %(uhour)02d:%(uminute)02d)</span><br/>
216   <span class="tags">tagged %(tags)s</span> -
217   <span class="comments">with %(comments)s
218     <a href="%(url)s/post/%(uuid)s#comments">comment(s)</a></span>
219 </span><br/>
220 <p/>
221 <div class="artbody">
222 """
223
224 default_article_footer = """
225 <p/>
226 </div>
227 </div>
228 """
229
230 default_comment_header = """
231 <div class="comment">
232 <a name="comment-%(number)d" />
233 <h3><a href="#comment-%(number)d">Comment #%(number)d</a></h3>
234 <span class="cominfo">by %(linked_author)s
235   on %(year)04d-%(month)02d-%(day)02d %(hour)02d:%(minute)02d</span>
236 <p/>
237 <div class="combody">
238 """
239
240 default_comment_footer = """
241 <p/>
242 </div>
243 </div>
244 """
245
246 default_comment_form = """
247 <div class="comform">
248 <a name="comment" />
249 <h3 class="comform"><a href="#comment">Your comment</a></h3>
250 <div class="comforminner">
251 <form method="%(form_method)s" action="%(form_action)s">
252 <div class="comformauthor">
253   <label for="comformauthor">Your name %(form_author_error)s</label>
254   <input type="text" class="comformauthor" id="comformauthor"
255          name="comformauthor" value="%(form_author)s" />
256 </div>
257 <div class="comformlink">
258   <label for="comformlink">Your link
259     <span class="comformoptional">(optional, will be published)</span>
260       %(form_link_error)s</label>
261   <input type="text" class="comformlink" id="comformlink"
262          name="comformlink" value="%(form_link)s" />
263   <div class="comformhelp">
264     like <span class="formurlexample">http://www.example.com/</span>
265     or <span class="formurlexample">mailto:you@example.com</span>
266   </div>
267 </div>
268 <div class="comformcaptcha">
269   <label for="comformcaptcha">Your humanity proof %(form_captcha_error)s</label>
270   <input type="text" class="comformcaptcha" id="comformcaptcha"
271          name="comformcaptcha" value="%(form_captcha)s" />
272   <div class="comformhelp">%(captcha_puzzle)s</div>
273 </div>
274 <div class="comformbody">
275   <label for="comformbody" class="comformbody">The comment
276     %(form_body_error)s</label>
277   <textarea class="comformbody" id="comformbody" name="comformbody" rows="15"
278             cols="80">%(form_body)s</textarea>
279   <div class="comformhelp">
280     in
281     <a href="http://docutils.sourceforge.net/docs/user/rst/quickref.html">\
282 RestructuredText</a> format, please
283   </div>
284 </div>
285 <div class="comformsend">
286   <button type="submit" class="comformsend" id="comformsend" name="comformsend">
287     Send comment
288   </button>
289 </div>
290 </form>
291 </div>
292 </div>
293 """
294
295 default_comment_error = '<span class="comformerror">(%(error)s)</span>'
296
297
298 # Default CSS
299 default_css = """
300 body {
301         font-family: sans-serif;
302         font-size: small;
303         width: 52em;
304 }
305
306 div.content {
307         width: 96%;
308 }
309
310 h1 {
311         font-size: large;
312         border-bottom: 2px solid #99F;
313         width: 100%;
314         margin-bottom: 1em;
315 }
316
317 h2 {
318         font-size: medium;
319         font-weigth: none;
320         margin-bottom: 1pt;
321         border-bottom: 1px solid #99C;
322 }
323
324 h3 {
325         font-size: small;
326         font-weigth: none;
327         margin-bottom: 1pt;
328         border-bottom: 1px solid #99C;
329 }
330
331 h1 a, h2 a, h3 a {
332         text-decoration: none;
333         color: black;
334 }
335
336 span.artinfo {
337         font-size: xx-small;
338 }
339
340 span.artinfo a {
341         text-decoration: none;
342         color: #339;
343 }
344
345 span.artinfo a:hover {
346         text-decoration: none;
347         color: blue;
348 }
349
350 div.artbody {
351         margin-left: 1em;
352 }
353
354 div.article {
355         margin-bottom: 2em;
356 }
357
358 span.cominfo {
359         font-size: xx-small;
360 }
361
362 span.cominfo a {
363         text-decoration: none;
364         color: #339;
365 }
366
367 span.cominfo a:hover {
368         text-decoration: none;
369         color: blue;
370 }
371
372 div.combody {
373         margin-left: 2em;
374 }
375
376 div.comment {
377         margin-left: 1em;
378         margin-bottom: 1em;
379 }
380
381 div.comforminner {
382         margin-left: 2em;
383 }
384
385 div.comform {
386         margin-left: 1em;
387         margin-bottom: 1em;
388 }
389
390 div.comform label {
391         display: block;
392         border-bottom: 1px solid #99C;
393         margin-top: 0.5em;
394         clear: both;
395 }
396
397 div.comform span.comformoptional {
398         font-size: xx-small;
399         color: #666;
400 }
401
402 div.comform input {
403         font-size: small;
404         width: 99%;
405 }
406
407 div.comformhelp {
408         font-size: xx-small;
409         text-align: right;
410         float: right;
411 }
412
413 span.formurlexample {
414         color: #111;
415         background-color: #EEF;
416         font-family: monospace;
417         padding-left: 0.2em;
418         padding-right: 0.2em;
419 }
420
421 textarea.comformbody {
422         font-family: monospace;
423         font-size: small;
424         width: 99%;
425         height: 15em;
426 }
427
428 button.comformsend {
429         margin-top: 0.5em;
430 }
431
432 span.comformerror {
433         color: #900;
434         font-size: xx-small;
435         margin-left: 0.5em;
436 }
437
438 hr {
439         float: left;
440         height: 2px;
441         border: 0;
442         background-color: #99F;
443         width: 60%;
444 }
445
446 div.footer {
447         margin-top: 1em;
448         padding-top: 0.4em;
449         width: 100%;
450         border-top: 2px solid #99F;
451         font-size: x-small;
452 }
453
454 div.footer a {
455         text-decoration: none;
456 }
457
458 /* Articles are enclosed in <div class="section"> */
459 div.section h1 {
460         font-size: small;
461         font-weigth: none;
462         width: 100%;
463         margin-bottom: 1pt;
464         border-bottom: 1px dotted #99C;
465 }
466
467 """
468
469
470 # Cache decorator
471 # It only works if the function is pure (that is, its return value depends
472 # only on its arguments), and if all the arguments are hash()eable.
473 def cached(f):
474         # do not decorate if the cache is disabled
475         if cache_path is None:
476                 return f
477
478         def decorate(*args, **kwargs):
479                 hashes = '-'.join( str(hash(x)) for x in args +
480                                 tuple(kwargs.items()) )
481                 fname = 'blitiri.%s.%s.cache' % (f.__name__, hashes)
482                 cache_file = os.path.join(cache_path, fname)
483                 try:
484                         s = open(cache_file).read()
485                 except:
486                         s = f(*args, **kwargs)
487                         open(cache_file, 'w').write(s)
488                 return s
489
490         return decorate
491
492
493 # helper functions
494 @cached
495 def rst_to_html(rst, secure = True):
496         settings = {
497                 'input_encoding': encoding,
498                 'output_encoding': 'utf8',
499                 'halt_level': 1,
500                 'traceback':  1,
501                 'file_insertion_enabled': secure,
502                 'raw_enabled': secure,
503         }
504         parts = publish_parts(rst, settings_overrides = settings,
505                                 writer_name = "html")
506         return parts['body'].encode('utf8')
507
508 def validate_rst(rst, secure = True):
509         try:
510                 rst_to_html(rst, secure)
511                 return None
512         except SystemMessage, e:
513                 desc = e.args[0].encode('utf-8') # the error string
514                 desc = desc[9:] # remove "<string>:"
515                 line = int(desc[:desc.find(':')] or 0) # get the line number
516                 desc = desc[desc.find(')')+2:-1] # remove (LEVEL/N)
517                 try:
518                         desc, context = desc.split('\n', 1)
519                 except ValueError:
520                         context = ''
521                 if desc.endswith('.'):
522                         desc = desc[:-1]
523                 return (line, desc, context)
524
525 def valid_link(link):
526         import re
527         scheme_re = r'^[a-zA-Z]+:'
528         mail_re = r"^[^ \t\n\r@<>()]+@[a-z0-9][a-z0-9\.\-_]*\.[a-z]+$"
529         url_re = r'^(?:[a-z0-9\-]+|[a-z0-9][a-z0-9\-\.\_]*\.[a-z]+)' \
530                         r'(?::[0-9]+)?(?:/.*)?$'
531
532         if re.match(scheme_re, link, re.I):
533                 scheme, rest = link.split(':', 1)
534                 # if we have an scheme and a rest, assume the link is valid
535                 # and return it as-is; otherwise (having just the scheme) is
536                 # invalid
537                 if rest:
538                         return link
539                 return None
540
541         # at this point, we don't have a scheme; we will try to recognize some
542         # common addresses (mail and http at the moment) and complete them to
543         # form a valid link, if we fail we will just claim it's invalid
544         if re.match(mail_re, link, re.I):
545                 return 'mailto:' + link
546         elif re.match(url_re, link, re.I):
547                 return 'http://' + link
548
549         return None
550
551 def sanitize(obj):
552         return cgi.escape(obj, quote = True)
553
554
555 # find out our URL, needed for syndication
556 try:
557         n = os.environ['SERVER_NAME']
558         p = os.environ['SERVER_PORT']
559         s = os.environ['SCRIPT_NAME']
560         if p == '80': p = ''
561         else: p = ':' + p
562         full_url = 'http://%s%s%s' % (n, p, s)
563 except KeyError:
564         full_url = 'Not needed'
565
566
567 class Templates (object):
568         def __init__(self, tpath, db, showyear = None):
569                 self.tpath = tpath
570                 self.db = db
571                 now = datetime.datetime.now()
572                 if not showyear:
573                         showyear = now.year
574
575                 self.vars = {
576                         'css_url': css_url,
577                         'title': title,
578                         'url': blog_url,
579                         'fullurl': full_url,
580                         'year': now.year,
581                         'month': now.month,
582                         'day': now.day,
583                         'showyear': showyear,
584                         'monthlinks': ' '.join(db.get_month_links(showyear)),
585                         'yearlinks': ' '.join(db.get_year_links()),
586                 }
587
588         def get_template(self, page_name, default_template, extra_vars = None):
589                 if extra_vars is None:
590                         vars = self.vars
591                 else:
592                         vars = self.vars.copy()
593                         vars.update(extra_vars)
594
595                 p = '%s/%s.html' % (self.tpath, page_name)
596                 if os.path.isfile(p):
597                         return open(p).read() % vars
598                 return default_template % vars
599
600         def get_main_header(self):
601                 return self.get_template('header', default_main_header)
602
603         def get_main_footer(self):
604                 return self.get_template('footer', default_main_footer)
605
606         def get_article_header(self, article):
607                 return self.get_template(
608                         'art_header', default_article_header, article.to_vars())
609
610         def get_article_footer(self, article):
611                 return self.get_template(
612                         'art_footer', default_article_footer, article.to_vars())
613
614         def get_comment_header(self, comment):
615                 vars = comment.to_vars()
616                 if comment.link:
617                         vars['linked_author'] = '<a href="%s">%s</a>' \
618                                         % (vars['link'], vars['author'])
619                 else:
620                         vars['linked_author'] = vars['author']
621                 return self.get_template(
622                         'com_header', default_comment_header, vars)
623
624         def get_comment_footer(self, comment):
625                 return self.get_template(
626                         'com_footer', default_comment_footer, comment.to_vars())
627
628         def get_comment_form(self, article, form_data, captcha_puzzle):
629                 vars = article.to_vars()
630                 vars.update(form_data.to_vars(self))
631                 vars['captcha_puzzle'] = captcha_puzzle
632                 return self.get_template(
633                         'com_form', default_comment_form, vars)
634
635         def get_comment_error(self, error):
636                 return self.get_template(
637                         'com_error', default_comment_error, dict(error=error))
638
639
640 class CommentFormData (object):
641         def __init__(self, author = '', link = '', captcha = '', body = ''):
642                 self.author = author
643                 self.link = link
644                 self.captcha = captcha
645                 self.body = body
646                 self.author_error = ''
647                 self.link_error = ''
648                 self.captcha_error = ''
649                 self.body_error = ''
650                 self.action = ''
651                 self.method = 'post'
652
653         def to_vars(self, template):
654                 render_error = template.get_comment_error
655                 a_error = self.author_error and render_error(self.author_error)
656                 l_error = self.link_error and render_error(self.link_error)
657                 c_error = self.captcha_error \
658                                 and render_error(self.captcha_error)
659                 b_error = self.body_error and render_error(self.body_error)
660                 return {
661                         'form_author': sanitize(self.author),
662                         'form_link': sanitize(self.link),
663                         'form_captcha': sanitize(self.captcha),
664                         'form_body': sanitize(self.body),
665
666                         'form_author_error': a_error,
667                         'form_link_error': l_error,
668                         'form_captcha_error': c_error,
669                         'form_body_error': b_error,
670
671                         'form_action': self.action,
672                         'form_method': self.method,
673                 }
674
675
676 class Comment (object):
677         def __init__(self, article, number, created = None):
678                 self.article = article
679                 self.number = number
680                 if created is None:
681                         self.created = datetime.datetime.now()
682                 else:
683                         self.created = created
684
685                 self.loaded = False
686
687                 # loaded on demand
688                 self._author = author
689                 self._link = ''
690                 self._raw_content = 'Removed comment'
691
692         @property
693         def author(self):
694                 if not self.loaded:
695                         self.load()
696                 return self._author
697
698         @property
699         def link(self):
700                 if not self.loaded:
701                         self.load()
702                 return self._link
703
704         @property
705         def raw_content(self):
706                 if not self.loaded:
707                         self.load()
708                 return self._raw_content
709
710         def set(self, author, raw_content, link = '', created = None):
711                 self.loaded = True
712                 self._author = author
713                 self._raw_content = raw_content
714                 self._link = link
715                 self.created = created or datetime.datetime.now()
716
717
718         def load(self):
719                 filename = os.path.join(comments_path, self.article.uuid,
720                                         str(self.number))
721                 try:
722                         raw = open(filename).readlines()
723                 except:
724                         return
725
726                 count = 0
727                 for l in raw:
728                         if ':' in l:
729                                 name, value = l.split(':', 1)
730                                 if name.lower() == 'author':
731                                         self._author = value.strip()
732                                 elif name.lower() == 'link':
733                                         self._link = value.strip()
734                         elif l == '\n':
735                                 # end of header
736                                 break
737                         count += 1
738                 self._raw_content = ''.join(raw[count + 1:])
739                 self.loaded = True
740
741         def save(self):
742                 filename = os.path.join(comments_path, self.article.uuid,
743                                         str(self.number))
744                 try:
745                         f = open(filename, 'w')
746                         f.write('Author: %s\n' % self.author)
747                         f.write('Link: %s\n' % self.link)
748                         f.write('\n')
749                         f.write(self.raw_content)
750                 except:
751                         return
752
753
754         def to_html(self):
755                 return rst_to_html(self.raw_content)
756
757         def to_vars(self):
758                 return {
759                         'number': self.number,
760                         'author': sanitize(self.author),
761                         'link': sanitize(self.link),
762                         'date': self.created.isoformat(' '),
763                         'created': self.created.isoformat(' '),
764
765                         'year': self.created.year,
766                         'month': self.created.month,
767                         'day': self.created.day,
768                         'hour': self.created.hour,
769                         'minute': self.created.minute,
770                         'second': self.created.second,
771                 }
772
773 class CommentDB (object):
774         def __init__(self, article):
775                 self.path = os.path.join(comments_path, article.uuid)
776                 # if comments were enabled after the article was added, we
777                 # will need to create the directory
778                 if not os.path.exists(self.path):
779                         os.mkdir(self.path, 0755)
780
781                 self.comments = []
782                 self.load(article)
783
784         def load(self, article):
785                 try:
786                         f = open(os.path.join(self.path, 'db'))
787                 except:
788                         return
789
790                 for l in f:
791                         # Each line has the following comma separated format:
792                         # number, created (epoch)
793                         # Empty lines are meaningful and represent removed
794                         # comments (so we can preserve the comment number)
795                         l = l.split(',')
796                         try:
797                                 n = int(l[0])
798                                 d = datetime.datetime.fromtimestamp(float(l[1]))
799                         except:
800                                 # Removed/invalid comment
801                                 self.comments.append(None)
802                                 continue
803                         self.comments.append(Comment(article, n, d))
804
805         def save(self):
806                 old_db = os.path.join(self.path, 'db')
807                 new_db = os.path.join(self.path, 'db.tmp')
808                 f = open(new_db, 'w')
809                 for c in self.comments:
810                         s = ''
811                         if c is not None:
812                                 s = ''
813                                 s += str(c.number) + ', '
814                                 s += str(time.mktime(c.created.timetuple()))
815                         s += '\n'
816                         f.write(s)
817                 f.close()
818                 os.rename(new_db, old_db)
819
820
821 class Article (object):
822         def __init__(self, path, created = None, updated = None):
823                 self.path = path
824                 self.created = created
825                 self.updated = updated
826                 self.uuid = "%08x" % zlib.crc32(self.path)
827
828                 self.loaded = False
829
830                 # loaded on demand
831                 self._title = 'Removed post'
832                 self._author = author
833                 self._tags = []
834                 self._raw_content = ''
835                 self._comments = []
836
837         @property
838         def title(self):
839                 if not self.loaded:
840                         self.load()
841                 return self._title
842
843         @property
844         def author(self):
845                 if not self.loaded:
846                         self.load()
847                 return self._author
848
849         @property
850         def tags(self):
851                 if not self.loaded:
852                         self.load()
853                 return self._tags
854
855         @property
856         def raw_content(self):
857                 if not self.loaded:
858                         self.load()
859                 return self._raw_content
860
861         @property
862         def comments(self):
863                 if not self.loaded:
864                         self.load()
865                 return self._comments
866
867         def __cmp__(self, other):
868                 if self.path == other.path:
869                         return 0
870                 if not self.created:
871                         return 1
872                 if not other.created:
873                         return -1
874                 if self.created < other.created:
875                         return -1
876                 return 1
877
878         def title_cmp(self, other):
879                 return cmp(self.title, other.title)
880
881
882         def add_comment(self, author, raw_content, link = ''):
883                 c = Comment(self, len(self.comments))
884                 c.set(author, raw_content, link)
885                 self.comments.append(c)
886                 return c
887
888
889         def load(self):
890                 # XXX this tweak is only needed for old DB format, where
891                 # article's paths started with a slash
892                 path = self.path
893                 if path.startswith('/'):
894                         path = path[1:]
895                 filename = os.path.join(data_path, path)
896                 try:
897                         raw = open(filename).readlines()
898                 except:
899                         return
900
901                 count = 0
902                 for l in raw:
903                         if ':' in l:
904                                 name, value = l.split(':', 1)
905                                 if name.lower() == 'title':
906                                         self._title = value.strip()
907                                 elif name.lower() == 'author':
908                                         self._author = value.strip()
909                                 elif name.lower() == 'tags':
910                                         ts = value.split(',')
911                                         ts = [t.strip() for t in ts]
912                                         self._tags = set(ts)
913                         elif l == '\n':
914                                 # end of header
915                                 break
916                         count += 1
917                 self._raw_content = ''.join(raw[count + 1:])
918                 db = CommentDB(self)
919                 self._comments = db.comments
920                 self.loaded = True
921
922         def to_html(self):
923                 return rst_to_html(self.raw_content)
924
925         def to_vars(self):
926                 return {
927                         'arttitle': sanitize(self.title),
928                         'author': sanitize(self.author),
929                         'date': self.created.isoformat(' '),
930                         'uuid': self.uuid,
931                         'tags': self.get_tags_links(),
932                         'comments': len(self.comments),
933
934                         'created': self.created.isoformat(' '),
935                         'ciso': self.created.isoformat(),
936                         'cyear': self.created.year,
937                         'cmonth': self.created.month,
938                         'cday': self.created.day,
939                         'chour': self.created.hour,
940                         'cminute': self.created.minute,
941                         'csecond': self.created.second,
942
943                         'updated': self.updated.isoformat(' '),
944                         'uiso': self.updated.isoformat(),
945                         'uyear': self.updated.year,
946                         'umonth': self.updated.month,
947                         'uday': self.updated.day,
948                         'uhour': self.updated.hour,
949                         'uminute': self.updated.minute,
950                         'usecond': self.updated.second,
951                 }
952
953         def get_tags_links(self):
954                 l = []
955                 tags = list(self.tags)
956                 tags.sort()
957                 for t in tags:
958                         l.append('<a class="tag" href="%s/tag/%s">%s</a>' % \
959                                 (blog_url, urllib.quote(t), sanitize(t) ))
960                 return ', '.join(l)
961
962
963 class ArticleDB (object):
964         def __init__(self, dbpath):
965                 self.dbpath = dbpath
966                 self.articles = []
967                 self.uuids = {}
968                 self.actyears = set()
969                 self.actmonths = set()
970                 self.load()
971
972         def get_articles(self, year = 0, month = 0, day = 0, tags = None):
973                 l = []
974                 for a in self.articles:
975                         if year and a.created.year != year: continue
976                         if month and a.created.month != month: continue
977                         if day and a.created.day != day: continue
978                         if tags and not tags.issubset(a.tags): continue
979
980                         l.append(a)
981
982                 return l
983
984         def get_article(self, uuid):
985                 return self.uuids[uuid]
986
987         def load(self):
988                 try:
989                         f = open(self.dbpath)
990                 except:
991                         return
992
993                 for l in f:
994                         # Each line has the following comma separated format:
995                         # path (relative to data_path), \
996                         #       created (epoch), \
997                         #       updated (epoch)
998                         try:
999                                 l = l.split(',')
1000                         except:
1001                                 continue
1002
1003                         a = Article(l[0],
1004                                 datetime.datetime.fromtimestamp(float(l[1])),
1005                                 datetime.datetime.fromtimestamp(float(l[2])))
1006                         self.uuids[a.uuid] = a
1007                         self.actyears.add(a.created.year)
1008                         self.actmonths.add((a.created.year, a.created.month))
1009                         self.articles.append(a)
1010
1011         def save(self):
1012                 f = open(self.dbpath + '.tmp', 'w')
1013                 for a in self.articles:
1014                         s = ''
1015                         s += a.path + ', '
1016                         s += str(time.mktime(a.created.timetuple())) + ', '
1017                         s += str(time.mktime(a.updated.timetuple())) + '\n'
1018                         f.write(s)
1019                 f.close()
1020                 os.rename(self.dbpath + '.tmp', self.dbpath)
1021
1022         def get_year_links(self):
1023                 yl = list(self.actyears)
1024                 yl.sort(reverse = True)
1025                 return [ '<a href="%s/%d/">%d</a>' % (blog_url, y, y)
1026                                 for y in yl ]
1027
1028         def get_month_links(self, year):
1029                 am = [ i[1] for i in self.actmonths if i[0] == year ]
1030                 ml = []
1031                 for i in range(1, 13):
1032                         name = calendar.month_name[i][:3]
1033                         if i in am:
1034                                 s = '<a href="%s/%d/%d/">%s</a>' % \
1035                                         ( blog_url, year, i, name )
1036                         else:
1037                                 s = name
1038                         ml.append(s)
1039                 return ml
1040
1041 #
1042 # Main
1043 #
1044
1045 def render_comments(article, template, form_data):
1046         print '<a name="comments" />'
1047         for c in article.comments:
1048                 if c is None:
1049                         continue
1050                 print template.get_comment_header(c)
1051                 print c.to_html()
1052                 print template.get_comment_footer(c)
1053         if not form_data:
1054                 form_data = CommentFormData()
1055         form_data.action = blog_url + '/comment/' + article.uuid + '#comment'
1056         captcha = captcha_method(article)
1057         print template.get_comment_form(article, form_data, captcha.puzzle)
1058
1059 def render_html(articles, db, actyear = None, show_comments = False,
1060                 redirect =  None, form_data = None):
1061         if redirect:
1062                 print 'Status: 303 See Other\r\n',
1063                 print 'Location: %s\r\n' % redirect,
1064         print 'Content-type: text/html; charset=utf-8\r\n',
1065         print '\r\n',
1066         template = Templates(templates_path, db, actyear)
1067         print template.get_main_header()
1068         for a in articles:
1069                 print template.get_article_header(a)
1070                 print a.to_html()
1071                 print template.get_article_footer(a)
1072                 if show_comments:
1073                         render_comments(a, template, form_data)
1074         print template.get_main_footer()
1075
1076 def render_artlist(articles, db, actyear = None):
1077         template = Templates(templates_path, db, actyear)
1078         print 'Content-type: text/html; charset=utf-8\n'
1079         print template.get_main_header()
1080         print '<h2>Articles</h2>'
1081         for a in articles:
1082                 print '<li><a href="%(url)s/post/%(uuid)s">%(title)s</a></li>' \
1083                         % {     'url': blog_url,
1084                                 'uuid': a.uuid,
1085                                 'title': a.title,
1086                                 'author': a.author,
1087                         }
1088         print template.get_main_footer()
1089
1090 def render_atom(articles):
1091         if len(articles) > 0:
1092                 updated = articles[0].updated.isoformat()
1093         else:
1094                 updated = datetime.datetime.now().isoformat()
1095
1096         print 'Content-type: application/atom+xml; charset=utf-8\n'
1097         print """<?xml version="1.0" encoding="utf-8"?>
1098
1099 <feed xmlns="http://www.w3.org/2005/Atom">
1100  <title>%(title)s</title>
1101  <link rel="alternate" type="text/html" href="%(url)s"/>
1102  <link rel="self" type="application/atom+xml" href="%(url)s/atom"/>
1103  <id>%(url)s</id> <!-- TODO: find a better <id>, see RFC 4151 -->
1104  <updated>%(updated)sZ</updated>
1105
1106         """ % {
1107                 'title': title,
1108                 'url': full_url,
1109                 'updated': updated,
1110         }
1111
1112         for a in articles:
1113                 vars = a.to_vars()
1114                 vars.update( {
1115                         'url': full_url,
1116                         'contents': a.to_html(),
1117                 } )
1118                 print """
1119   <entry>
1120     <title>%(arttitle)s</title>
1121     <author><name>%(author)s</name></author>
1122     <link href="%(url)s/post/%(uuid)s" />
1123     <id>%(url)s/post/%(uuid)s</id>
1124     <summary>%(arttitle)s</summary>
1125     <published>%(ciso)sZ</published>
1126     <updated>%(uiso)sZ</updated>
1127     <content type="xhtml">
1128       <div xmlns="http://www.w3.org/1999/xhtml"><p>
1129 %(contents)s
1130       </p></div>
1131     </content>
1132   </entry>
1133                 """ % vars
1134         print "</feed>"
1135
1136
1137 def render_style():
1138         print 'Content-type: text/css\r\n\r\n',
1139         print default_css
1140
1141 def handle_cgi():
1142         import cgitb; cgitb.enable()
1143
1144         form = cgi.FieldStorage()
1145         year = int(form.getfirst("year", 0))
1146         month = int(form.getfirst("month", 0))
1147         day = int(form.getfirst("day", 0))
1148         tags = set(form.getlist("tag"))
1149         uuid = None
1150         atom = False
1151         style = False
1152         post = False
1153         post_preview = False
1154         artlist = False
1155         comment = False
1156
1157         if os.environ.has_key('PATH_INFO'):
1158                 path_info = os.environ['PATH_INFO']
1159                 style = path_info == '/style'
1160                 atom = path_info == '/atom'
1161                 tag = path_info.startswith('/tag/')
1162                 post = path_info.startswith('/post/')
1163                 post_preview = path_info.startswith('/preview/post/')
1164                 artlist = path_info.startswith('/list')
1165                 comment = path_info.startswith('/comment/') and enable_comments
1166                 if not style and not atom and not post and not post_preview \
1167                                 and not tag and not comment and not artlist:
1168                         date = path_info.split('/')[1:]
1169                         try:
1170                                 if len(date) > 1 and date[0]:
1171                                         year = int(date[0])
1172                                 if len(date) > 2 and date[1]:
1173                                         month = int(date[1])
1174                                 if len(date) > 3 and date[2]:
1175                                         day = int(date[2])
1176                         except ValueError:
1177                                 pass
1178                 elif post:
1179                         uuid = path_info.replace('/post/', '')
1180                         uuid = uuid.replace('/', '')
1181                 elif post_preview:
1182                         art_path = path_info.replace('/preview/post/', '')
1183                         art_path = urllib.unquote_plus(art_path)
1184                         art_path = os.path.join(data_path, art_path)
1185                         art_path = os.path.realpath(art_path)
1186                         common = os.path.commonprefix([data_path, art_path])
1187                         if common != data_path: # something nasty happened
1188                                 post_preview = False
1189                         art_path = art_path[len(data_path)+1:]
1190                 elif tag:
1191                         t = path_info.replace('/tag/', '')
1192                         t = t.replace('/', '')
1193                         t = urllib.unquote_plus(t)
1194                         tags = set((t,))
1195                 elif comment:
1196                         uuid = path_info.replace('/comment/', '')
1197                         uuid = uuid.replace('#comment', '')
1198                         uuid = uuid.replace('/', '')
1199                         author = form.getfirst('comformauthor', '')
1200                         link = form.getfirst('comformlink', '')
1201                         captcha = form.getfirst('comformcaptcha', '')
1202                         body = form.getfirst('comformbody', '')
1203
1204         db = ArticleDB(os.path.join(data_path, 'db'))
1205         if atom:
1206                 articles = db.get_articles(tags = tags)
1207                 articles.sort(reverse = True)
1208                 render_atom(articles[:10])
1209         elif style:
1210                 render_style()
1211         elif post:
1212                 render_html( [db.get_article(uuid)], db, year, enable_comments )
1213         elif post_preview:
1214                 article = Article(art_path, datetime.datetime.now(),
1215                                         datetime.datetime.now())
1216                 render_html( [article], db, year, enable_comments )
1217         elif artlist:
1218                 articles = db.get_articles()
1219                 articles.sort(cmp = Article.title_cmp)
1220                 render_artlist(articles, db)
1221         elif comment and enable_comments:
1222                 form_data = CommentFormData(author.strip().replace('\n', ' '),
1223                                 link.strip().replace('\n', ' '), captcha,
1224                                 body.replace('\r', ''))
1225                 article = db.get_article(uuid)
1226                 captcha = captcha_method(article)
1227                 redirect = False
1228                 valid = True
1229                 if not form_data.author:
1230                         form_data.author_error = 'please, enter your name'
1231                         valid = False
1232                 if form_data.link:
1233                         link = valid_link(form_data.link)
1234                         if link:
1235                                 form_data.link = link
1236                         else:
1237                                 form_data.link_error = 'please, enter a ' \
1238                                                 'valid link'
1239                                 valid = False
1240                 if not captcha.validate(form_data):
1241                         form_data.captcha_error = captcha.help
1242                         valid = False
1243                 if not form_data.body:
1244                         form_data.body_error = 'please, write a comment'
1245                         valid = False
1246                 else:
1247                         error = validate_rst(form_data.body, secure=False)
1248                         if error is not None:
1249                                 (line, desc, ctx) = error
1250                                 at = ''
1251                                 if line:
1252                                         at = ' at line %d' % line
1253                                 form_data.body_error = 'error%s: %s' \
1254                                                 % (at, desc)
1255                                 valid = False
1256                 if valid:
1257                         c = article.add_comment(form_data.author,
1258                                         form_data.body, form_data.link)
1259                         c.save()
1260                         cdb = CommentDB(article)
1261                         cdb.comments = article.comments
1262                         cdb.save()
1263                         redirect = blog_url + '/post/' + uuid + '#comment-' \
1264                                         + str(c.number)
1265                 render_html( [article], db, year, enable_comments, redirect,
1266                                 form_data )
1267         else:
1268                 articles = db.get_articles(year, month, day, tags)
1269                 articles.sort(reverse = True)
1270                 if not year and not month and not day and not tags:
1271                         articles = articles[:10]
1272                 render_html(articles, db, year)
1273
1274
1275 def usage():
1276         print 'Usage: %s {add|rm|update} article_path' % sys.argv[0]
1277
1278 def handle_cmd():
1279         if len(sys.argv) != 3:
1280                 usage()
1281                 return 1
1282
1283         cmd = sys.argv[1]
1284         art_path = os.path.realpath(sys.argv[2])
1285
1286         if os.path.commonprefix([data_path, art_path]) != data_path:
1287                 print "Error: article (%s) must be inside data_path (%s)" % \
1288                                 (art_path, data_path)
1289                 return 1
1290         art_path = art_path[len(data_path)+1:]
1291
1292         db_filename = os.path.join(data_path, 'db')
1293         if not os.path.isfile(db_filename):
1294                 open(db_filename, 'w').write('')
1295         db = ArticleDB(db_filename)
1296
1297         if cmd == 'add':
1298                 article = Article(art_path, datetime.datetime.now(),
1299                                         datetime.datetime.now())
1300                 for a in db.articles:
1301                         if a == article:
1302                                 print 'Error: article already exists'
1303                                 return 1
1304                 db.articles.append(article)
1305                 db.save()
1306                 if enable_comments:
1307                         comment_dir = os.path.join(comments_path, article.uuid)
1308                         try:
1309                                 os.mkdir(comment_dir, 0775)
1310                         except OSError, e:
1311                                 if e.errno != errno.EEXIST:
1312                                         print "Error: can't create comments " \
1313                                                 "directory %s (%s)" \
1314                                                         % (comment_dir, e)
1315                                 # otherwise is probably a removed and re-added
1316                                 # article
1317         elif cmd == 'rm':
1318                 article = Article(art_path)
1319                 for a in db.articles:
1320                         if a == article:
1321                                 break
1322                 else:
1323                         print "Error: no such article"
1324                         return 1
1325                 if enable_comments:
1326                         r = raw_input('Remove comments [y/N]? ')
1327                 db.articles.remove(a)
1328                 db.save()
1329                 if enable_comments and r.lower() == 'y':
1330                         shutil.rmtree(os.path.join(comments_path, a.uuid))
1331         elif cmd == 'update':
1332                 article = Article(art_path)
1333                 for a in db.articles:
1334                         if a == article:
1335                                 break
1336                 else:
1337                         print "Error: no such article"
1338                         return 1
1339                 a.updated = datetime.datetime.now()
1340                 db.save()
1341         else:
1342                 usage()
1343                 return 1
1344
1345         return 0
1346
1347
1348 if os.environ.has_key('GATEWAY_INTERFACE'):
1349         i = datetime.datetime.now()
1350         handle_cgi()
1351         f = datetime.datetime.now()
1352         print '<!-- render time: %s -->' % (f-i)
1353 else:
1354         sys.exit(handle_cmd())
1355
1356