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