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