]> git.llucax.com Git - software/blitiri.git/blob - blitiri.cgi
Create the comments directory for an article if it doesn't exist
[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         scheme_re = r'^[a-zA-Z]+:'
528         mail_re = r"^[^ \t\n\r@<>()]+@[a-z0-9][a-z0-9\.\-_]*\.[a-z]+$"
529         url_re = r'^(?:[a-z0-9\-]+|[a-z0-9][a-z0-9\-\.\_]*\.[a-z]+)' \
530                         r'(?::[0-9]+)?(?:/.*)?$'
531
532         if re.match(scheme_re, link, re.I):
533                 scheme, rest = link.split(':', 1)
534                 # if we have an scheme and a rest, assume the link is valid
535                 # and return it as-is; otherwise (having just the scheme) is
536                 # invalid
537                 if rest:
538                         return link
539                 return None
540
541         # at this point, we don't have a scheme; we will try to recognize some
542         # common addresses (mail and http at the moment) and complete them to
543         # form a valid link, if we fail we will just claim it's invalid
544         if re.match(mail_re, link, re.I):
545                 return 'mailto:' + link
546         elif re.match(url_re, link, re.I):
547                 return 'http://' + link
548
549         return None
550
551 def sanitize(obj):
552         if isinstance(obj, basestring):
553                 return cgi.escape(obj, True)
554         return obj
555
556
557 # find out our URL, needed for syndication
558 try:
559         n = os.environ['SERVER_NAME']
560         p = os.environ['SERVER_PORT']
561         s = os.environ['SCRIPT_NAME']
562         if p == '80': p = ''
563         else: p = ':' + p
564         full_url = 'http://%s%s%s' % (n, p, s)
565 except KeyError:
566         full_url = 'Not needed'
567
568
569 class Templates (object):
570         def __init__(self, tpath, db, showyear = None):
571                 self.tpath = tpath
572                 self.db = db
573                 now = datetime.datetime.now()
574                 if not showyear:
575                         showyear = now.year
576
577                 self.vars = {
578                         'css_url': css_url,
579                         'title': title,
580                         'url': blog_url,
581                         'fullurl': full_url,
582                         'year': now.year,
583                         'month': now.month,
584                         'day': now.day,
585                         'showyear': showyear,
586                         'monthlinks': ' '.join(db.get_month_links(showyear)),
587                         'yearlinks': ' '.join(db.get_year_links()),
588                 }
589
590         def get_template(self, page_name, default_template, extra_vars = None):
591                 if extra_vars is None:
592                         vars = self.vars
593                 else:
594                         vars = self.vars.copy()
595                         vars.update(extra_vars)
596
597                 p = '%s/%s.html' % (self.tpath, page_name)
598                 if os.path.isfile(p):
599                         return open(p).read() % vars
600                 return default_template % vars
601
602         def get_main_header(self):
603                 return self.get_template('header', default_main_header)
604
605         def get_main_footer(self):
606                 return self.get_template('footer', default_main_footer)
607
608         def get_article_header(self, article):
609                 return self.get_template(
610                         'art_header', default_article_header, article.to_vars())
611
612         def get_article_footer(self, article):
613                 return self.get_template(
614                         'art_footer', default_article_footer, article.to_vars())
615
616         def get_comment_header(self, comment):
617                 vars = comment.to_vars()
618                 if comment.link:
619                         vars['linked_author'] = '<a href="%s">%s</a>' \
620                                         % (comment.link, comment.author)
621                 else:
622                         vars['linked_author'] = comment.author
623                 return self.get_template(
624                         'com_header', default_comment_header, vars)
625
626         def get_comment_footer(self, comment):
627                 return self.get_template(
628                         'com_footer', default_comment_footer, comment.to_vars())
629
630         def get_comment_form(self, article, form_data, captcha_puzzle):
631                 vars = article.to_vars()
632                 vars.update(form_data.to_vars(self))
633                 vars['captcha_puzzle'] = captcha_puzzle
634                 return self.get_template(
635                         'com_form', default_comment_form, vars)
636
637         def get_comment_error(self, error):
638                 return self.get_template(
639                         'com_error', default_comment_error, dict(error=error))
640
641
642 class CommentFormData (object):
643         def __init__(self, author = '', link = '', captcha = '', body = ''):
644                 self.author = author
645                 self.link = link
646                 self.captcha = captcha
647                 self.body = body
648                 self.author_error = ''
649                 self.link_error = ''
650                 self.captcha_error = ''
651                 self.body_error = ''
652                 self.action = ''
653                 self.method = 'post'
654
655         def to_vars(self, template):
656                 render_error = template.get_comment_error
657                 a_error = self.author_error and render_error(self.author_error)
658                 l_error = self.link_error and render_error(self.link_error)
659                 c_error = self.captcha_error \
660                                 and render_error(self.captcha_error)
661                 b_error = self.body_error and render_error(self.body_error)
662                 return {
663                         'form_author': sanitize(self.author),
664                         'form_link': sanitize(self.link),
665                         'form_captcha': sanitize(self.captcha),
666                         'form_body': sanitize(self.body),
667
668                         'form_author_error': a_error,
669                         'form_link_error': l_error,
670                         'form_captcha_error': c_error,
671                         'form_body_error': b_error,
672
673                         'form_action': self.action,
674                         'form_method': self.method,
675                 }
676
677
678 class Comment (object):
679         def __init__(self, article, number, created = None):
680                 self.article = article
681                 self.number = number
682                 if created is None:
683                         self.created = datetime.datetime.now()
684                 else:
685                         self.created = created
686
687                 self.loaded = False
688
689                 # loaded on demand
690                 self._author = author
691                 self._link = ''
692                 self._raw_content = 'Removed comment'
693
694         @property
695         def author(self):
696                 if not self.loaded:
697                         self.load()
698                 return self._author
699
700         @property
701         def link(self):
702                 if not self.loaded:
703                         self.load()
704                 return self._link
705
706         @property
707         def raw_content(self):
708                 if not self.loaded:
709                         self.load()
710                 return self._raw_content
711
712         def set(self, author, raw_content, link = '', created = None):
713                 self.loaded = True
714                 self._author = author
715                 self._raw_content = raw_content
716                 self._link = link
717                 self.created = created or datetime.datetime.now()
718
719
720         def load(self):
721                 filename = os.path.join(comments_path, self.article.uuid,
722                                         str(self.number))
723                 try:
724                         raw = open(filename).readlines()
725                 except:
726                         return
727
728                 count = 0
729                 for l in raw:
730                         if ':' in l:
731                                 name, value = l.split(':', 1)
732                                 if name.lower() == 'author':
733                                         self._author = value.strip()
734                                 elif name.lower() == 'link':
735                                         self._link = value.strip()
736                         elif l == '\n':
737                                 # end of header
738                                 break
739                         count += 1
740                 self._raw_content = ''.join(raw[count + 1:])
741                 self.loaded = True
742
743         def save(self):
744                 filename = os.path.join(comments_path, self.article.uuid,
745                                         str(self.number))
746                 try:
747                         f = open(filename, 'w')
748                         f.write('Author: %s\n' % self.author)
749                         f.write('Link: %s\n' % self.link)
750                         f.write('\n')
751                         f.write(self.raw_content)
752                 except:
753                         return
754
755
756         def to_html(self):
757                 return rst_to_html(self.raw_content)
758
759         def to_vars(self):
760                 return {
761                         'number': self.number,
762                         'author': sanitize(self.author),
763                         'link': sanitize(self.link),
764                         'date': self.created.isoformat(' '),
765                         'created': self.created.isoformat(' '),
766
767                         'year': self.created.year,
768                         'month': self.created.month,
769                         'day': self.created.day,
770                         'hour': self.created.hour,
771                         'minute': self.created.minute,
772                         'second': self.created.second,
773                 }
774
775 class CommentDB (object):
776         def __init__(self, article):
777                 self.path = os.path.join(comments_path, article.uuid)
778                 # if comments were enabled after the article was added, we
779                 # will need to create the directory
780                 if not os.path.exists(self.path):
781                         os.mkdir(self.path, 0755)
782
783                 self.comments = []
784                 self.load(article)
785
786         def load(self, article):
787                 try:
788                         f = open(os.path.join(self.path, 'db'))
789                 except:
790                         return
791
792                 for l in f:
793                         # Each line has the following comma separated format:
794                         # number, created (epoch)
795                         # Empty lines are meaningful and represent removed
796                         # comments (so we can preserve the comment number)
797                         l = l.split(',')
798                         try:
799                                 n = int(l[0])
800                                 d = datetime.datetime.fromtimestamp(float(l[1]))
801                         except:
802                                 # Removed/invalid comment
803                                 self.comments.append(None)
804                                 continue
805                         self.comments.append(Comment(article, n, d))
806
807         def save(self):
808                 old_db = os.path.join(self.path, 'db')
809                 new_db = os.path.join(self.path, 'db.tmp')
810                 f = open(new_db, 'w')
811                 for c in self.comments:
812                         s = ''
813                         if c is not None:
814                                 s = ''
815                                 s += str(c.number) + ', '
816                                 s += str(time.mktime(c.created.timetuple()))
817                         s += '\n'
818                         f.write(s)
819                 f.close()
820                 os.rename(new_db, old_db)
821
822
823 class Article (object):
824         def __init__(self, path, created = None, updated = None):
825                 self.path = path
826                 self.created = created
827                 self.updated = updated
828                 self.uuid = "%08x" % zlib.crc32(self.path)
829
830                 self.loaded = False
831
832                 # loaded on demand
833                 self._title = 'Removed post'
834                 self._author = author
835                 self._tags = []
836                 self._raw_content = ''
837                 self._comments = []
838
839         @property
840         def title(self):
841                 if not self.loaded:
842                         self.load()
843                 return self._title
844
845         @property
846         def author(self):
847                 if not self.loaded:
848                         self.load()
849                 return self._author
850
851         @property
852         def tags(self):
853                 if not self.loaded:
854                         self.load()
855                 return self._tags
856
857         @property
858         def raw_content(self):
859                 if not self.loaded:
860                         self.load()
861                 return self._raw_content
862
863         @property
864         def comments(self):
865                 if not self.loaded:
866                         self.load()
867                 return self._comments
868
869         def __cmp__(self, other):
870                 if self.path == other.path:
871                         return 0
872                 if not self.created:
873                         return 1
874                 if not other.created:
875                         return -1
876                 if self.created < other.created:
877                         return -1
878                 return 1
879
880         def title_cmp(self, other):
881                 return cmp(self.title, other.title)
882
883
884         def add_comment(self, author, raw_content, link = ''):
885                 c = Comment(self, len(self.comments))
886                 c.set(author, raw_content, link)
887                 self.comments.append(c)
888                 return c
889
890
891         def load(self):
892                 # XXX this tweak is only needed for old DB format, where
893                 # article's paths started with a slash
894                 path = self.path
895                 if path.startswith('/'):
896                         path = path[1:]
897                 filename = os.path.join(data_path, path)
898                 try:
899                         raw = open(filename).readlines()
900                 except:
901                         return
902
903                 count = 0
904                 for l in raw:
905                         if ':' in l:
906                                 name, value = l.split(':', 1)
907                                 if name.lower() == 'title':
908                                         self._title = value.strip()
909                                 elif name.lower() == 'author':
910                                         self._author = value.strip()
911                                 elif name.lower() == 'tags':
912                                         ts = value.split(',')
913                                         ts = [t.strip() for t in ts]
914                                         self._tags = set(ts)
915                         elif l == '\n':
916                                 # end of header
917                                 break
918                         count += 1
919                 self._raw_content = ''.join(raw[count + 1:])
920                 db = CommentDB(self)
921                 self._comments = db.comments
922                 self.loaded = True
923
924         def to_html(self):
925                 return rst_to_html(self.raw_content)
926
927         def to_vars(self):
928                 return {
929                         'arttitle': sanitize(self.title),
930                         'author': sanitize(self.author),
931                         'date': self.created.isoformat(' '),
932                         'uuid': self.uuid,
933                         'tags': self.get_tags_links(),
934                         'comments': len(self.comments),
935
936                         'created': self.created.isoformat(' '),
937                         'ciso': self.created.isoformat(),
938                         'cyear': self.created.year,
939                         'cmonth': self.created.month,
940                         'cday': self.created.day,
941                         'chour': self.created.hour,
942                         'cminute': self.created.minute,
943                         'csecond': self.created.second,
944
945                         'updated': self.updated.isoformat(' '),
946                         'uiso': self.updated.isoformat(),
947                         'uyear': self.updated.year,
948                         'umonth': self.updated.month,
949                         'uday': self.updated.day,
950                         'uhour': self.updated.hour,
951                         'uminute': self.updated.minute,
952                         'usecond': self.updated.second,
953                 }
954
955         def get_tags_links(self):
956                 l = []
957                 tags = list(self.tags)
958                 tags.sort()
959                 for t in tags:
960                         l.append('<a class="tag" href="%s/tag/%s">%s</a>' % \
961                                 (blog_url, urllib.quote(t), sanitize(t) ))
962                 return ', '.join(l)
963
964
965 class ArticleDB (object):
966         def __init__(self, dbpath):
967                 self.dbpath = dbpath
968                 self.articles = []
969                 self.uuids = {}
970                 self.actyears = set()
971                 self.actmonths = set()
972                 self.load()
973
974         def get_articles(self, year = 0, month = 0, day = 0, tags = None):
975                 l = []
976                 for a in self.articles:
977                         if year and a.created.year != year: continue
978                         if month and a.created.month != month: continue
979                         if day and a.created.day != day: continue
980                         if tags and not tags.issubset(a.tags): continue
981
982                         l.append(a)
983
984                 return l
985
986         def get_article(self, uuid):
987                 return self.uuids[uuid]
988
989         def load(self):
990                 try:
991                         f = open(self.dbpath)
992                 except:
993                         return
994
995                 for l in f:
996                         # Each line has the following comma separated format:
997                         # path (relative to data_path), \
998                         #       created (epoch), \
999                         #       updated (epoch)
1000                         try:
1001                                 l = l.split(',')
1002                         except:
1003                                 continue
1004
1005                         a = Article(l[0],
1006                                 datetime.datetime.fromtimestamp(float(l[1])),
1007                                 datetime.datetime.fromtimestamp(float(l[2])))
1008                         self.uuids[a.uuid] = a
1009                         self.actyears.add(a.created.year)
1010                         self.actmonths.add((a.created.year, a.created.month))
1011                         self.articles.append(a)
1012
1013         def save(self):
1014                 f = open(self.dbpath + '.tmp', 'w')
1015                 for a in self.articles:
1016                         s = ''
1017                         s += a.path + ', '
1018                         s += str(time.mktime(a.created.timetuple())) + ', '
1019                         s += str(time.mktime(a.updated.timetuple())) + '\n'
1020                         f.write(s)
1021                 f.close()
1022                 os.rename(self.dbpath + '.tmp', self.dbpath)
1023
1024         def get_year_links(self):
1025                 yl = list(self.actyears)
1026                 yl.sort(reverse = True)
1027                 return [ '<a href="%s/%d/">%d</a>' % (blog_url, y, y)
1028                                 for y in yl ]
1029
1030         def get_month_links(self, year):
1031                 am = [ i[1] for i in self.actmonths if i[0] == year ]
1032                 ml = []
1033                 for i in range(1, 13):
1034                         name = calendar.month_name[i][:3]
1035                         if i in am:
1036                                 s = '<a href="%s/%d/%d/">%s</a>' % \
1037                                         ( blog_url, year, i, name )
1038                         else:
1039                                 s = name
1040                         ml.append(s)
1041                 return ml
1042
1043 #
1044 # Main
1045 #
1046
1047 def render_comments(article, template, form_data):
1048         print '<a name="comments" />'
1049         for c in article.comments:
1050                 if c is None:
1051                         continue
1052                 print template.get_comment_header(c)
1053                 print c.to_html()
1054                 print template.get_comment_footer(c)
1055         if not form_data:
1056                 form_data = CommentFormData()
1057         form_data.action = blog_url + '/comment/' + article.uuid + '#comment'
1058         captcha = captcha_method(article)
1059         print template.get_comment_form(article, form_data, captcha.puzzle)
1060
1061 def render_html(articles, db, actyear = None, show_comments = False,
1062                 redirect =  None, form_data = None):
1063         if redirect:
1064                 print 'Status: 303 See Other\r\n',
1065                 print 'Location: %s\r\n' % redirect,
1066         print 'Content-type: text/html; charset=utf-8\r\n',
1067         print '\r\n',
1068         template = Templates(templates_path, db, actyear)
1069         print template.get_main_header()
1070         for a in articles:
1071                 print template.get_article_header(a)
1072                 print a.to_html()
1073                 print template.get_article_footer(a)
1074                 if show_comments:
1075                         render_comments(a, template, form_data)
1076         print template.get_main_footer()
1077
1078 def render_artlist(articles, db, actyear = None):
1079         template = Templates(templates_path, db, actyear)
1080         print 'Content-type: text/html; charset=utf-8\n'
1081         print template.get_main_header()
1082         print '<h2>Articles</h2>'
1083         for a in articles:
1084                 print '<li><a href="%(url)s/post/%(uuid)s">%(title)s</a></li>' \
1085                         % {     'url': blog_url,
1086                                 'uuid': a.uuid,
1087                                 'title': a.title,
1088                                 'author': a.author,
1089                         }
1090         print template.get_main_footer()
1091
1092 def render_atom(articles):
1093         if len(articles) > 0:
1094                 updated = articles[0].updated.isoformat()
1095         else:
1096                 updated = datetime.datetime.now().isoformat()
1097
1098         print 'Content-type: application/atom+xml; charset=utf-8\n'
1099         print """<?xml version="1.0" encoding="utf-8"?>
1100
1101 <feed xmlns="http://www.w3.org/2005/Atom">
1102  <title>%(title)s</title>
1103  <link rel="alternate" type="text/html" href="%(url)s"/>
1104  <link rel="self" type="application/atom+xml" href="%(url)s/atom"/>
1105  <id>%(url)s</id> <!-- TODO: find a better <id>, see RFC 4151 -->
1106  <updated>%(updated)sZ</updated>
1107
1108         """ % {
1109                 'title': title,
1110                 'url': full_url,
1111                 'updated': updated,
1112         }
1113
1114         for a in articles:
1115                 vars = a.to_vars()
1116                 vars.update( {
1117                         'url': full_url,
1118                         'contents': a.to_html(),
1119                 } )
1120                 print """
1121   <entry>
1122     <title>%(arttitle)s</title>
1123     <author><name>%(author)s</name></author>
1124     <link href="%(url)s/post/%(uuid)s" />
1125     <id>%(url)s/post/%(uuid)s</id>
1126     <summary>%(arttitle)s</summary>
1127     <published>%(ciso)sZ</published>
1128     <updated>%(uiso)sZ</updated>
1129     <content type="xhtml">
1130       <div xmlns="http://www.w3.org/1999/xhtml"><p>
1131 %(contents)s
1132       </p></div>
1133     </content>
1134   </entry>
1135                 """ % vars
1136         print "</feed>"
1137
1138
1139 def render_style():
1140         print 'Content-type: text/css\r\n\r\n',
1141         print default_css
1142
1143 def handle_cgi():
1144         import cgitb; cgitb.enable()
1145
1146         form = cgi.FieldStorage()
1147         year = int(form.getfirst("year", 0))
1148         month = int(form.getfirst("month", 0))
1149         day = int(form.getfirst("day", 0))
1150         tags = set(form.getlist("tag"))
1151         uuid = None
1152         atom = False
1153         style = False
1154         post = False
1155         post_preview = False
1156         artlist = False
1157         comment = False
1158
1159         if os.environ.has_key('PATH_INFO'):
1160                 path_info = os.environ['PATH_INFO']
1161                 style = path_info == '/style'
1162                 atom = path_info == '/atom'
1163                 tag = path_info.startswith('/tag/')
1164                 post = path_info.startswith('/post/')
1165                 post_preview = path_info.startswith('/preview/post/')
1166                 artlist = path_info.startswith('/list')
1167                 comment = path_info.startswith('/comment/') and enable_comments
1168                 if not style and not atom and not post and not post_preview \
1169                                 and not tag and not comment and not artlist:
1170                         date = path_info.split('/')[1:]
1171                         try:
1172                                 if len(date) > 1 and date[0]:
1173                                         year = int(date[0])
1174                                 if len(date) > 2 and date[1]:
1175                                         month = int(date[1])
1176                                 if len(date) > 3 and date[2]:
1177                                         day = int(date[2])
1178                         except ValueError:
1179                                 pass
1180                 elif post:
1181                         uuid = path_info.replace('/post/', '')
1182                         uuid = uuid.replace('/', '')
1183                 elif post_preview:
1184                         art_path = path_info.replace('/preview/post/', '')
1185                         art_path = urllib.unquote_plus(art_path)
1186                         art_path = os.path.join(data_path, art_path)
1187                         art_path = os.path.realpath(art_path)
1188                         common = os.path.commonprefix([data_path, art_path])
1189                         if common != data_path: # something nasty happened
1190                                 post_preview = False
1191                         art_path = art_path[len(data_path)+1:]
1192                 elif tag:
1193                         t = path_info.replace('/tag/', '')
1194                         t = t.replace('/', '')
1195                         t = urllib.unquote_plus(t)
1196                         tags = set((t,))
1197                 elif comment:
1198                         uuid = path_info.replace('/comment/', '')
1199                         uuid = uuid.replace('#comment', '')
1200                         uuid = uuid.replace('/', '')
1201                         author = form.getfirst('comformauthor', '')
1202                         link = form.getfirst('comformlink', '')
1203                         captcha = form.getfirst('comformcaptcha', '')
1204                         body = form.getfirst('comformbody', '')
1205
1206         db = ArticleDB(os.path.join(data_path, 'db'))
1207         if atom:
1208                 articles = db.get_articles(tags = tags)
1209                 articles.sort(reverse = True)
1210                 render_atom(articles[:10])
1211         elif style:
1212                 render_style()
1213         elif post:
1214                 render_html( [db.get_article(uuid)], db, year, enable_comments )
1215         elif post_preview:
1216                 article = Article(art_path, datetime.datetime.now(),
1217                                         datetime.datetime.now())
1218                 render_html( [article], db, year, enable_comments )
1219         elif artlist:
1220                 articles = db.get_articles()
1221                 articles.sort(cmp = Article.title_cmp)
1222                 render_artlist(articles, db)
1223         elif comment:
1224                 form_data = CommentFormData(author.strip().replace('\n', ' '),
1225                                 link.strip().replace('\n', ' '), captcha,
1226                                 body.replace('\r', ''))
1227                 article = db.get_article(uuid)
1228                 captcha = captcha_method(article)
1229                 redirect = False
1230                 valid = True
1231                 if not form_data.author:
1232                         form_data.author_error = 'please, enter your name'
1233                         valid = False
1234                 if form_data.link:
1235                         link = valid_link(form_data.link)
1236                         if link:
1237                                 form_data.link = link
1238                         else:
1239                                 form_data.link_error = 'please, enter a ' \
1240                                                 'valid link'
1241                                 valid = False
1242                 if not captcha.validate(form_data):
1243                         form_data.captcha_error = captcha.help
1244                         valid = False
1245                 if not form_data.body:
1246                         form_data.body_error = 'please, write a comment'
1247                         valid = False
1248                 else:
1249                         error = validate_rst(form_data.body, secure=False)
1250                         if error is not None:
1251                                 (line, desc, ctx) = error
1252                                 at = ''
1253                                 if line:
1254                                         at = ' at line %d' % line
1255                                 form_data.body_error = 'error%s: %s' \
1256                                                 % (at, desc)
1257                                 valid = False
1258                 if valid:
1259                         c = article.add_comment(form_data.author,
1260                                         form_data.body, form_data.link)
1261                         c.save()
1262                         cdb = CommentDB(article)
1263                         cdb.comments = article.comments
1264                         cdb.save()
1265                         redirect = blog_url + '/post/' + uuid + '#comment-' \
1266                                         + str(c.number)
1267                 render_html( [article], db, year, enable_comments, redirect,
1268                                 form_data )
1269         else:
1270                 articles = db.get_articles(year, month, day, tags)
1271                 articles.sort(reverse = True)
1272                 if not year and not month and not day and not tags:
1273                         articles = articles[:10]
1274                 render_html(articles, db, year)
1275
1276
1277 def usage():
1278         print 'Usage: %s {add|rm|update} article_path' % sys.argv[0]
1279
1280 def handle_cmd():
1281         if len(sys.argv) != 3:
1282                 usage()
1283                 return 1
1284
1285         cmd = sys.argv[1]
1286         art_path = os.path.realpath(sys.argv[2])
1287
1288         if os.path.commonprefix([data_path, art_path]) != data_path:
1289                 print "Error: article (%s) must be inside data_path (%s)" % \
1290                                 (art_path, data_path)
1291                 return 1
1292         art_path = art_path[len(data_path)+1:]
1293
1294         db_filename = os.path.join(data_path, 'db')
1295         if not os.path.isfile(db_filename):
1296                 open(db_filename, 'w').write('')
1297         db = ArticleDB(db_filename)
1298
1299         if cmd == 'add':
1300                 article = Article(art_path, datetime.datetime.now(),
1301                                         datetime.datetime.now())
1302                 for a in db.articles:
1303                         if a == article:
1304                                 print 'Error: article already exists'
1305                                 return 1
1306                 db.articles.append(article)
1307                 db.save()
1308                 if enable_comments:
1309                         comment_dir = os.path.join(comments_path, article.uuid)
1310                         try:
1311                                 os.mkdir(comment_dir, 0775)
1312                         except OSError, e:
1313                                 if e.errno != errno.EEXIST:
1314                                         print "Error: can't create comments " \
1315                                                 "directory %s (%s)" \
1316                                                         % (comment_dir, e)
1317                                 # otherwise is probably a removed and re-added
1318                                 # article
1319         elif cmd == 'rm':
1320                 article = Article(art_path)
1321                 for a in db.articles:
1322                         if a == article:
1323                                 break
1324                 else:
1325                         print "Error: no such article"
1326                         return 1
1327                 if enable_comments:
1328                         r = raw_input('Remove comments [y/N]? ')
1329                 db.articles.remove(a)
1330                 db.save()
1331                 if enable_comments and r.lower() == 'y':
1332                         shutil.rmtree(os.path.join(comments_path, a.uuid))
1333         elif cmd == 'update':
1334                 article = Article(art_path)
1335                 for a in db.articles:
1336                         if a == article:
1337                                 break
1338                 else:
1339                         print "Error: no such article"
1340                         return 1
1341                 a.updated = datetime.datetime.now()
1342                 db.save()
1343         else:
1344                 usage()
1345                 return 1
1346
1347         return 0
1348
1349
1350 if os.environ.has_key('GATEWAY_INTERFACE'):
1351         i = datetime.datetime.now()
1352         handle_cgi()
1353         f = datetime.datetime.now()
1354         print '<!-- render time: %s -->' % (f-i)
1355 else:
1356         sys.exit(handle_cmd())
1357
1358