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