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