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