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