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