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