]> git.llucax.com Git - software/blitiri.git/blob - blitiri.cgi
Fix valid_link() to avoid scheme repetition
[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                 self.comments = []
779                 self.load(article)
780
781         def load(self, article):
782                 try:
783                         f = open(os.path.join(self.path, 'db'))
784                 except:
785                         return
786
787                 for l in f:
788                         # Each line has the following comma separated format:
789                         # number, created (epoch)
790                         # Empty lines are meaningful and represent removed
791                         # comments (so we can preserve the comment number)
792                         l = l.split(',')
793                         try:
794                                 n = int(l[0])
795                                 d = datetime.datetime.fromtimestamp(float(l[1]))
796                         except:
797                                 # Removed/invalid comment
798                                 self.comments.append(None)
799                                 continue
800                         self.comments.append(Comment(article, n, d))
801
802         def save(self):
803                 old_db = os.path.join(self.path, 'db')
804                 new_db = os.path.join(self.path, 'db.tmp')
805                 f = open(new_db, 'w')
806                 for c in self.comments:
807                         s = ''
808                         if c is not None:
809                                 s = ''
810                                 s += str(c.number) + ', '
811                                 s += str(time.mktime(c.created.timetuple()))
812                         s += '\n'
813                         f.write(s)
814                 f.close()
815                 os.rename(new_db, old_db)
816
817
818 class Article (object):
819         def __init__(self, path, created = None, updated = None):
820                 self.path = path
821                 self.created = created
822                 self.updated = updated
823                 self.uuid = "%08x" % zlib.crc32(self.path)
824
825                 self.loaded = False
826
827                 # loaded on demand
828                 self._title = 'Removed post'
829                 self._author = author
830                 self._tags = []
831                 self._raw_content = ''
832                 self._comments = []
833
834         @property
835         def title(self):
836                 if not self.loaded:
837                         self.load()
838                 return self._title
839
840         @property
841         def author(self):
842                 if not self.loaded:
843                         self.load()
844                 return self._author
845
846         @property
847         def tags(self):
848                 if not self.loaded:
849                         self.load()
850                 return self._tags
851
852         @property
853         def raw_content(self):
854                 if not self.loaded:
855                         self.load()
856                 return self._raw_content
857
858         @property
859         def comments(self):
860                 if not self.loaded:
861                         self.load()
862                 return self._comments
863
864         def __cmp__(self, other):
865                 if self.path == other.path:
866                         return 0
867                 if not self.created:
868                         return 1
869                 if not other.created:
870                         return -1
871                 if self.created < other.created:
872                         return -1
873                 return 1
874
875         def title_cmp(self, other):
876                 return cmp(self.title, other.title)
877
878
879         def add_comment(self, author, raw_content, link = ''):
880                 c = Comment(self, len(self.comments))
881                 c.set(author, raw_content, link)
882                 self.comments.append(c)
883                 return c
884
885
886         def load(self):
887                 # XXX this tweak is only needed for old DB format, where
888                 # article's paths started with a slash
889                 path = self.path
890                 if path.startswith('/'):
891                         path = path[1:]
892                 filename = os.path.join(data_path, path)
893                 try:
894                         raw = open(filename).readlines()
895                 except:
896                         return
897
898                 count = 0
899                 for l in raw:
900                         if ':' in l:
901                                 name, value = l.split(':', 1)
902                                 if name.lower() == 'title':
903                                         self._title = value.strip()
904                                 elif name.lower() == 'author':
905                                         self._author = value.strip()
906                                 elif name.lower() == 'tags':
907                                         ts = value.split(',')
908                                         ts = [t.strip() for t in ts]
909                                         self._tags = set(ts)
910                         elif l == '\n':
911                                 # end of header
912                                 break
913                         count += 1
914                 self._raw_content = ''.join(raw[count + 1:])
915                 db = CommentDB(self)
916                 self._comments = db.comments
917                 self.loaded = True
918
919         def to_html(self):
920                 return rst_to_html(self.raw_content)
921
922         def to_vars(self):
923                 return {
924                         'arttitle': sanitize(self.title),
925                         'author': sanitize(self.author),
926                         'date': self.created.isoformat(' '),
927                         'uuid': self.uuid,
928                         'tags': self.get_tags_links(),
929                         'comments': len(self.comments),
930
931                         'created': self.created.isoformat(' '),
932                         'ciso': self.created.isoformat(),
933                         'cyear': self.created.year,
934                         'cmonth': self.created.month,
935                         'cday': self.created.day,
936                         'chour': self.created.hour,
937                         'cminute': self.created.minute,
938                         'csecond': self.created.second,
939
940                         'updated': self.updated.isoformat(' '),
941                         'uiso': self.updated.isoformat(),
942                         'uyear': self.updated.year,
943                         'umonth': self.updated.month,
944                         'uday': self.updated.day,
945                         'uhour': self.updated.hour,
946                         'uminute': self.updated.minute,
947                         'usecond': self.updated.second,
948                 }
949
950         def get_tags_links(self):
951                 l = []
952                 tags = list(self.tags)
953                 tags.sort()
954                 for t in tags:
955                         l.append('<a class="tag" href="%s/tag/%s">%s</a>' % \
956                                 (blog_url, urllib.quote(t), sanitize(t) ))
957                 return ', '.join(l)
958
959
960 class ArticleDB (object):
961         def __init__(self, dbpath):
962                 self.dbpath = dbpath
963                 self.articles = []
964                 self.uuids = {}
965                 self.actyears = set()
966                 self.actmonths = set()
967                 self.load()
968
969         def get_articles(self, year = 0, month = 0, day = 0, tags = None):
970                 l = []
971                 for a in self.articles:
972                         if year and a.created.year != year: continue
973                         if month and a.created.month != month: continue
974                         if day and a.created.day != day: continue
975                         if tags and not tags.issubset(a.tags): continue
976
977                         l.append(a)
978
979                 return l
980
981         def get_article(self, uuid):
982                 return self.uuids[uuid]
983
984         def load(self):
985                 try:
986                         f = open(self.dbpath)
987                 except:
988                         return
989
990                 for l in f:
991                         # Each line has the following comma separated format:
992                         # path (relative to data_path), \
993                         #       created (epoch), \
994                         #       updated (epoch)
995                         try:
996                                 l = l.split(',')
997                         except:
998                                 continue
999
1000                         a = Article(l[0],
1001                                 datetime.datetime.fromtimestamp(float(l[1])),
1002                                 datetime.datetime.fromtimestamp(float(l[2])))
1003                         self.uuids[a.uuid] = a
1004                         self.actyears.add(a.created.year)
1005                         self.actmonths.add((a.created.year, a.created.month))
1006                         self.articles.append(a)
1007
1008         def save(self):
1009                 f = open(self.dbpath + '.tmp', 'w')
1010                 for a in self.articles:
1011                         s = ''
1012                         s += a.path + ', '
1013                         s += str(time.mktime(a.created.timetuple())) + ', '
1014                         s += str(time.mktime(a.updated.timetuple())) + '\n'
1015                         f.write(s)
1016                 f.close()
1017                 os.rename(self.dbpath + '.tmp', self.dbpath)
1018
1019         def get_year_links(self):
1020                 yl = list(self.actyears)
1021                 yl.sort(reverse = True)
1022                 return [ '<a href="%s/%d/">%d</a>' % (blog_url, y, y)
1023                                 for y in yl ]
1024
1025         def get_month_links(self, year):
1026                 am = [ i[1] for i in self.actmonths if i[0] == year ]
1027                 ml = []
1028                 for i in range(1, 13):
1029                         name = calendar.month_name[i][:3]
1030                         if i in am:
1031                                 s = '<a href="%s/%d/%d/">%s</a>' % \
1032                                         ( blog_url, year, i, name )
1033                         else:
1034                                 s = name
1035                         ml.append(s)
1036                 return ml
1037
1038 #
1039 # Main
1040 #
1041
1042 def render_comments(article, template, form_data):
1043         print '<a name="comments" />'
1044         for c in article.comments:
1045                 if c is None:
1046                         continue
1047                 print template.get_comment_header(c)
1048                 print c.to_html()
1049                 print template.get_comment_footer(c)
1050         if not form_data:
1051                 form_data = CommentFormData()
1052         form_data.action = blog_url + '/comment/' + article.uuid + '#comment'
1053         captcha = captcha_method(article)
1054         print template.get_comment_form(article, form_data, captcha.puzzle)
1055
1056 def render_html(articles, db, actyear = None, show_comments = False,
1057                 redirect =  None, form_data = None):
1058         if redirect:
1059                 print 'Status: 303 See Other\r\n',
1060                 print 'Location: %s\r\n' % redirect,
1061         print 'Content-type: text/html; charset=utf-8\r\n',
1062         print '\r\n',
1063         template = Templates(templates_path, db, actyear)
1064         print template.get_main_header()
1065         for a in articles:
1066                 print template.get_article_header(a)
1067                 print a.to_html()
1068                 print template.get_article_footer(a)
1069                 if show_comments:
1070                         render_comments(a, template, form_data)
1071         print template.get_main_footer()
1072
1073 def render_artlist(articles, db, actyear = None):
1074         template = Templates(templates_path, db, actyear)
1075         print 'Content-type: text/html; charset=utf-8\n'
1076         print template.get_main_header()
1077         print '<h2>Articles</h2>'
1078         for a in articles:
1079                 print '<li><a href="%(url)s/post/%(uuid)s">%(title)s</a></li>' \
1080                         % {     'url': blog_url,
1081                                 'uuid': a.uuid,
1082                                 'title': a.title,
1083                                 'author': a.author,
1084                         }
1085         print template.get_main_footer()
1086
1087 def render_atom(articles):
1088         if len(articles) > 0:
1089                 updated = articles[0].updated.isoformat()
1090         else:
1091                 updated = datetime.datetime.now().isoformat()
1092
1093         print 'Content-type: application/atom+xml; charset=utf-8\n'
1094         print """<?xml version="1.0" encoding="utf-8"?>
1095
1096 <feed xmlns="http://www.w3.org/2005/Atom">
1097  <title>%(title)s</title>
1098  <link rel="alternate" type="text/html" href="%(url)s"/>
1099  <link rel="self" type="application/atom+xml" href="%(url)s/atom"/>
1100  <id>%(url)s</id> <!-- TODO: find a better <id>, see RFC 4151 -->
1101  <updated>%(updated)sZ</updated>
1102
1103         """ % {
1104                 'title': title,
1105                 'url': full_url,
1106                 'updated': updated,
1107         }
1108
1109         for a in articles:
1110                 vars = a.to_vars()
1111                 vars.update( {
1112                         'url': full_url,
1113                         'contents': a.to_html(),
1114                 } )
1115                 print """
1116   <entry>
1117     <title>%(arttitle)s</title>
1118     <author><name>%(author)s</name></author>
1119     <link href="%(url)s/post/%(uuid)s" />
1120     <id>%(url)s/post/%(uuid)s</id>
1121     <summary>%(arttitle)s</summary>
1122     <published>%(ciso)sZ</published>
1123     <updated>%(uiso)sZ</updated>
1124     <content type="xhtml">
1125       <div xmlns="http://www.w3.org/1999/xhtml"><p>
1126 %(contents)s
1127       </p></div>
1128     </content>
1129   </entry>
1130                 """ % vars
1131         print "</feed>"
1132
1133
1134 def render_style():
1135         print 'Content-type: text/css\r\n\r\n',
1136         print default_css
1137
1138 def handle_cgi():
1139         import cgitb; cgitb.enable()
1140
1141         form = cgi.FieldStorage()
1142         year = int(form.getfirst("year", 0))
1143         month = int(form.getfirst("month", 0))
1144         day = int(form.getfirst("day", 0))
1145         tags = set(form.getlist("tag"))
1146         uuid = None
1147         atom = False
1148         style = False
1149         post = False
1150         post_preview = False
1151         artlist = False
1152         comment = False
1153
1154         if os.environ.has_key('PATH_INFO'):
1155                 path_info = os.environ['PATH_INFO']
1156                 style = path_info == '/style'
1157                 atom = path_info == '/atom'
1158                 tag = path_info.startswith('/tag/')
1159                 post = path_info.startswith('/post/')
1160                 post_preview = path_info.startswith('/preview/post/')
1161                 artlist = path_info.startswith('/list')
1162                 comment = path_info.startswith('/comment/') and enable_comments
1163                 if not style and not atom and not post and not post_preview \
1164                                 and not tag and not comment and not artlist:
1165                         date = path_info.split('/')[1:]
1166                         try:
1167                                 if len(date) > 1 and date[0]:
1168                                         year = int(date[0])
1169                                 if len(date) > 2 and date[1]:
1170                                         month = int(date[1])
1171                                 if len(date) > 3 and date[2]:
1172                                         day = int(date[2])
1173                         except ValueError:
1174                                 pass
1175                 elif post:
1176                         uuid = path_info.replace('/post/', '')
1177                         uuid = uuid.replace('/', '')
1178                 elif post_preview:
1179                         art_path = path_info.replace('/preview/post/', '')
1180                         art_path = urllib.unquote_plus(art_path)
1181                         art_path = os.path.join(data_path, art_path)
1182                         art_path = os.path.realpath(art_path)
1183                         common = os.path.commonprefix([data_path, art_path])
1184                         if common != data_path: # something nasty happened
1185                                 post_preview = False
1186                         art_path = art_path[len(data_path)+1:]
1187                 elif tag:
1188                         t = path_info.replace('/tag/', '')
1189                         t = t.replace('/', '')
1190                         t = urllib.unquote_plus(t)
1191                         tags = set((t,))
1192                 elif comment:
1193                         uuid = path_info.replace('/comment/', '')
1194                         uuid = uuid.replace('#comment', '')
1195                         uuid = uuid.replace('/', '')
1196                         author = form.getfirst('comformauthor', '')
1197                         link = form.getfirst('comformlink', '')
1198                         captcha = form.getfirst('comformcaptcha', '')
1199                         body = form.getfirst('comformbody', '')
1200
1201         db = ArticleDB(os.path.join(data_path, 'db'))
1202         if atom:
1203                 articles = db.get_articles(tags = tags)
1204                 articles.sort(reverse = True)
1205                 render_atom(articles[:10])
1206         elif style:
1207                 render_style()
1208         elif post:
1209                 render_html( [db.get_article(uuid)], db, year, enable_comments )
1210         elif post_preview:
1211                 article = Article(art_path, datetime.datetime.now(),
1212                                         datetime.datetime.now())
1213                 render_html( [article], db, year, enable_comments )
1214         elif artlist:
1215                 articles = db.get_articles()
1216                 articles.sort(cmp = Article.title_cmp)
1217                 render_artlist(articles, db)
1218         elif comment:
1219                 form_data = CommentFormData(author.strip().replace('\n', ' '),
1220                                 link.strip().replace('\n', ' '), captcha,
1221                                 body.replace('\r', ''))
1222                 article = db.get_article(uuid)
1223                 captcha = captcha_method(article)
1224                 redirect = False
1225                 valid = True
1226                 if not form_data.author:
1227                         form_data.author_error = 'please, enter your name'
1228                         valid = False
1229                 if form_data.link:
1230                         link = valid_link(form_data.link)
1231                         if link:
1232                                 form_data.link = link
1233                         else:
1234                                 form_data.link_error = 'please, enter a ' \
1235                                                 'valid link'
1236                                 valid = False
1237                 if not captcha.validate(form_data):
1238                         form_data.captcha_error = captcha.help
1239                         valid = False
1240                 if not form_data.body:
1241                         form_data.body_error = 'please, write a comment'
1242                         valid = False
1243                 else:
1244                         error = validate_rst(form_data.body, secure=False)
1245                         if error is not None:
1246                                 (line, desc, ctx) = error
1247                                 at = ''
1248                                 if line:
1249                                         at = ' at line %d' % line
1250                                 form_data.body_error = 'error%s: %s' \
1251                                                 % (at, desc)
1252                                 valid = False
1253                 if valid:
1254                         c = article.add_comment(form_data.author,
1255                                         form_data.body, form_data.link)
1256                         c.save()
1257                         cdb = CommentDB(article)
1258                         cdb.comments = article.comments
1259                         cdb.save()
1260                         redirect = blog_url + '/post/' + uuid + '#comment-' \
1261                                         + str(c.number)
1262                 render_html( [article], db, year, enable_comments, redirect,
1263                                 form_data )
1264         else:
1265                 articles = db.get_articles(year, month, day, tags)
1266                 articles.sort(reverse = True)
1267                 if not year and not month and not day and not tags:
1268                         articles = articles[:10]
1269                 render_html(articles, db, year)
1270
1271
1272 def usage():
1273         print 'Usage: %s {add|rm|update} article_path' % sys.argv[0]
1274
1275 def handle_cmd():
1276         if len(sys.argv) != 3:
1277                 usage()
1278                 return 1
1279
1280         cmd = sys.argv[1]
1281         art_path = os.path.realpath(sys.argv[2])
1282
1283         if os.path.commonprefix([data_path, art_path]) != data_path:
1284                 print "Error: article (%s) must be inside data_path (%s)" % \
1285                                 (art_path, data_path)
1286                 return 1
1287         art_path = art_path[len(data_path)+1:]
1288
1289         db_filename = os.path.join(data_path, 'db')
1290         if not os.path.isfile(db_filename):
1291                 open(db_filename, 'w').write('')
1292         db = ArticleDB(db_filename)
1293
1294         if cmd == 'add':
1295                 article = Article(art_path, datetime.datetime.now(),
1296                                         datetime.datetime.now())
1297                 for a in db.articles:
1298                         if a == article:
1299                                 print 'Error: article already exists'
1300                                 return 1
1301                 db.articles.append(article)
1302                 db.save()
1303                 if enable_comments:
1304                         comment_dir = os.path.join(comments_path, article.uuid)
1305                         try:
1306                                 os.mkdir(comment_dir, 0775)
1307                         except OSError, e:
1308                                 if e.errno != errno.EEXIST:
1309                                         print "Error: can't create comments " \
1310                                                 "directory %s (%s)" \
1311                                                         % (comment_dir, e)
1312                                 # otherwise is probably a removed and re-added
1313                                 # article
1314         elif cmd == 'rm':
1315                 article = Article(art_path)
1316                 for a in db.articles:
1317                         if a == article:
1318                                 break
1319                 else:
1320                         print "Error: no such article"
1321                         return 1
1322                 if enable_comments:
1323                         r = raw_input('Remove comments [y/N]? ')
1324                 db.articles.remove(a)
1325                 db.save()
1326                 if enable_comments and r.lower() == 'y':
1327                         shutil.rmtree(os.path.join(comments_path, a.uuid))
1328         elif cmd == 'update':
1329                 article = Article(art_path)
1330                 for a in db.articles:
1331                         if a == article:
1332                                 break
1333                 else:
1334                         print "Error: no such article"
1335                         return 1
1336                 a.updated = datetime.datetime.now()
1337                 db.save()
1338         else:
1339                 usage()
1340                 return 1
1341
1342         return 0
1343
1344
1345 if os.environ.has_key('GATEWAY_INTERFACE'):
1346         i = datetime.datetime.now()
1347         handle_cgi()
1348         f = datetime.datetime.now()
1349         print '<!-- render time: %s -->' % (f-i)
1350 else:
1351         sys.exit(handle_cmd())
1352
1353