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