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