#!/usr/bin/env python #coding: utf8 # blitiri - A single-file blog engine. # Alberto Bertogli (albertito@gmail.com) # # Configuration section # # You can edit these values, or create a file named "config.py" and put them # there to make updating easier. The ones in config.py take precedence. # # Directory where entries are stored data_path = "/tmp/blog/data" # Are comments allowed? (if False, comments_path option is not used) enable_comments = False # Directory where comments are stored (must be writeable by the web server) comments_path = "/tmp/blog/comments" # Path where templates are stored. Use an empty string for the built-in # default templates. If they're not found, the built-in ones will be used. templates_path = "/tmp/blog/templates" # Path where the cache is stored (must be writeable by the web server); # set to None to disable. When enabled, you must take care of cleaning it up # every once in a while. #cache_path = "/tmp/blog/cache" cache_path = None # URL to the blog, including the name. Can be a full URL or just the path. blog_url = "/blog/blitiri.cgi" # Style sheet (CSS) URL. Can be relative or absolute. To use the built-in # default, set it to blog_url + "/style". css_url = blog_url + "/style" # Blog title title = "I don't like blogs" # Default author author = "Hartmut Kegan" # Article encoding encoding = "utf8" # Captcha method to use. At the moment only "title" is supported, but if you # are keen with Python you can provide your own captcha implementation, see # below for details. captcha_method = "title" # How many articles to show in the index index_articles = 10 # # End of configuration # DO *NOT* EDIT ANYTHING PAST HERE # import sys import os import errno import shutil import time import datetime import calendar import zlib import urllib import cgi from docutils.core import publish_parts from docutils.utils import SystemMessage # Before importing the config, add our cwd to the Python path sys.path.append(os.getcwd()) # Load the config file, if there is one try: from config import * except: pass # Pimp *_path config variables to support relative paths data_path = os.path.realpath(data_path) templates_path = os.path.realpath(templates_path) # # Captcha classes # # They must follow the interface described below. # # Constructor: # Captcha(article) -> constructor, takes an article[1] as argument # Attributes: # puzzle -> a string with the puzzle the user must solve to prove he is # not a bot (can be raw HTML) # help -> a string with extra instructions, shown only when the user # failed to solve the puzzle # Methods: # validate(form_data) -> based on the form data[2], returns True if # the user has solved the puzzle uccessfully # (False otherwise). # # Note you must ensure that the puzzle attribute and validate() method can # "communicate" because they are executed in different requests. You can pass a # cookie or just calculate the answer based on the article's data, for example. # # [1] article is an object with all the article's information: # path -> string # created -> datetime # updated -> datetime # uuid -> string (unique ID) # title -> string # author -> string # tags -> list of strings # raw_contents -> string in rst format # comments -> list of Comment objects (not too relevant here) # [2] form_data is an object with the form fields (all strings): # author, author_error # link, link_error # catpcha, captcha_error # body, body_error # action, method class TitleCaptcha (object): "Captcha that uses the article's title for the puzzle" def __init__(self, article): self.article = article words = article.title.split() self.nword = hash(article.title) % len(words) % 5 self.answer = words[self.nword] self.help = 'gotcha, damn spam bot!' @property def puzzle(self): nword = self.nword + 1 if nword == 1: n = '1st' elif nword == 2: n = '2nd' elif nword == 3: n = '3rd' else: n = str(nword) + 'th' return "enter the %s word of the article's title" % n def validate(self, form_data): if form_data.captcha.lower() == self.answer.lower(): return True return False known_captcha_methods = { 'title': TitleCaptcha, } # If the configured captcha method was a known string, replace it by the # matching class; otherwise assume it's already a class and leave it # alone. This way the user can either use one of our methods, or provide one # of his/her own. if captcha_method in known_captcha_methods: captcha_method = known_captcha_methods[captcha_method] # Default template default_main_header = """\ %(title)s

%(title)s

""" default_main_footer = """
""" default_article_header = """

%(arttitle)s

by %(author)s on %(cyear)04d-\ %(cmonth)02d-\ %(cday)02d\ %(chour)02d:%(cminute)02d (updated on %(uyear)04d-\ %(umonth)02d-\ %(uday)02d\ %(uhour)02d:%(uminute)02d)
tagged %(tags)s - with %(comments)s comment(s)

""" default_article_footer = """

""" default_comment_header = """

Comment #%(number)d

by %(linked_author)s on %(year)04d-%(month)02d-%(day)02d %(hour)02d:%(minute)02d

""" default_comment_footer = """

""" default_comment_form = """

Your comment

%(captcha_puzzle)s
in \ RestructuredText format, please
""" default_comment_error = '(%(error)s)' # Default CSS default_css = """ body { font-family: sans-serif; font-size: small; width: 52em; } div.content { width: 96%; } h1 { font-size: large; border-bottom: 2px solid #99F; width: 100%; margin-bottom: 1em; } h2 { font-size: medium; font-weigth: none; margin-bottom: 1pt; border-bottom: 1px solid #99C; } h3 { font-size: small; font-weigth: none; margin-bottom: 1pt; border-bottom: 1px solid #99C; } h1 a, h2 a, h3 a { text-decoration: none; color: black; } span.artinfo { font-size: xx-small; } span.artinfo a { text-decoration: none; color: #339; } span.artinfo a:hover { text-decoration: none; color: blue; } div.artbody { margin-left: 1em; } div.article { margin-bottom: 2em; } span.cominfo { font-size: xx-small; } span.cominfo a { text-decoration: none; color: #339; } span.cominfo a:hover { text-decoration: none; color: blue; } div.combody { margin-left: 2em; } div.comment { margin-left: 1em; margin-bottom: 1em; } div.comforminner { margin-left: 2em; } div.comform { margin-left: 1em; margin-bottom: 1em; } div.comform label { display: block; border-bottom: 1px solid #99C; margin-top: 0.5em; clear: both; } div.comform span.comformoptional { font-size: xx-small; color: #666; } div.comform input { font-size: small; width: 99%; } div.comformhelp { font-size: xx-small; text-align: right; float: right; } span.formurlexample { color: #111; background-color: #EEF; font-family: monospace; padding-left: 0.2em; padding-right: 0.2em; } textarea.comformbody { font-family: monospace; font-size: small; width: 99%; height: 15em; } button.comformsend { margin-top: 0.5em; } span.comformerror { color: #900; font-size: xx-small; margin-left: 0.5em; } hr { float: left; height: 2px; border: 0; background-color: #99F; width: 60%; } div.footer { margin-top: 1em; padding-top: 0.4em; width: 100%; border-top: 2px solid #99F; font-size: x-small; } div.footer a { text-decoration: none; } /* Articles are enclosed in
*/ div.section h1 { font-size: small; font-weigth: none; width: 100%; margin-bottom: 1pt; border-bottom: 1px dotted #99C; } """ # Cache decorator # It only works if the function is pure (that is, its return value depends # only on its arguments), and if all the arguments are hash()eable. def cached(f): # do not decorate if the cache is disabled if cache_path is None: return f def decorate(*args, **kwargs): hashes = '-'.join( str(hash(x)) for x in args + tuple(kwargs.items()) ) fname = 'blitiri.%s.%s.cache' % (f.__name__, hashes) cache_file = os.path.join(cache_path, fname) try: s = open(cache_file).read() except: s = f(*args, **kwargs) open(cache_file, 'w').write(s) return s return decorate # helper functions @cached def rst_to_html(rst, secure = True): settings = { 'input_encoding': encoding, 'output_encoding': 'utf8', 'halt_level': 1, 'traceback': 1, 'file_insertion_enabled': secure, 'raw_enabled': secure, } parts = publish_parts(rst, settings_overrides = settings, writer_name = "html") return parts['body'].encode('utf8') def validate_rst(rst, secure = True): try: rst_to_html(rst, secure) return None except SystemMessage, e: desc = e.args[0].encode('utf-8') # the error string desc = desc[9:] # remove ":" line = int(desc[:desc.find(':')] or 0) # get the line number desc = desc[desc.find(')')+2:-1] # remove (LEVEL/N) try: desc, context = desc.split('\n', 1) except ValueError: context = '' if desc.endswith('.'): desc = desc[:-1] return (line, desc, context) def valid_link(link): import re scheme_re = r'^[a-zA-Z]+:' mail_re = r"^[^ \t\n\r@<>()]+@[a-z0-9][a-z0-9\.\-_]*\.[a-z]+$" url_re = r'^(?:[a-z0-9\-]+|[a-z0-9][a-z0-9\-\.\_]*\.[a-z]+)' \ r'(?::[0-9]+)?(?:/.*)?$' if re.match(scheme_re, link, re.I): scheme, rest = link.split(':', 1) # if we have an scheme and a rest, assume the link is valid # and return it as-is; otherwise (having just the scheme) is # invalid if rest: return link return None # at this point, we don't have a scheme; we will try to recognize some # common addresses (mail and http at the moment) and complete them to # form a valid link, if we fail we will just claim it's invalid if re.match(mail_re, link, re.I): return 'mailto:' + link elif re.match(url_re, link, re.I): return 'https://' + link return None def sanitize(obj): return cgi.escape(obj, quote = True) # find out our URL, needed for syndication try: n = os.environ['SERVER_NAME'] # Removed port because it messes up when behind a proxy #p = os.environ['SERVER_PORT'] s = os.environ['SCRIPT_NAME'] #if p == '80': p = '' #else: p = ':' + p full_url = 'https://%s%s' % (n, s) except KeyError: full_url = 'Not needed' class Templates (object): def __init__(self, tpath, db, showyear = None): self.tpath = tpath self.db = db now = datetime.datetime.now() if not showyear: showyear = now.year self.vars = { 'css_url': css_url, 'title': title, 'url': blog_url, 'fullurl': full_url, 'year': now.year, 'month': now.month, 'day': now.day, 'showyear': showyear, 'monthlinks': ' '.join(db.get_month_links(showyear)), 'yearlinks': ' '.join(db.get_year_links()), 'taglinks': ' '.join(db.get_tag_links()), } def get_template(self, page_name, default_template, extra_vars = None): if extra_vars is None: vars = self.vars else: vars = self.vars.copy() vars.update(extra_vars) p = '%s/%s.html' % (self.tpath, page_name) if os.path.isfile(p): return open(p).read() % vars return default_template % vars def get_main_header(self): return self.get_template('header', default_main_header) def get_main_footer(self): return self.get_template('footer', default_main_footer) def get_article_header(self, article): return self.get_template( 'art_header', default_article_header, article.to_vars()) def get_article_footer(self, article): return self.get_template( 'art_footer', default_article_footer, article.to_vars()) def get_comment_header(self, comment): vars = comment.to_vars() if comment.link: vars['linked_author'] = '%s' \ % (vars['link'], vars['author']) else: vars['linked_author'] = vars['author'] return self.get_template( 'com_header', default_comment_header, vars) def get_comment_footer(self, comment): return self.get_template( 'com_footer', default_comment_footer, comment.to_vars()) def get_comment_form(self, article, form_data, captcha_puzzle): vars = article.to_vars() vars.update(form_data.to_vars(self)) vars['captcha_puzzle'] = captcha_puzzle return self.get_template( 'com_form', default_comment_form, vars) def get_comment_error(self, error): return self.get_template( 'com_error', default_comment_error, dict(error=error)) class CommentFormData (object): def __init__(self, author = '', link = '', captcha = '', body = ''): self.author = author self.link = link self.captcha = captcha self.body = body self.author_error = '' self.link_error = '' self.captcha_error = '' self.body_error = '' self.action = '' self.method = 'post' def to_vars(self, template): render_error = template.get_comment_error a_error = self.author_error and render_error(self.author_error) l_error = self.link_error and render_error(self.link_error) c_error = self.captcha_error \ and render_error(self.captcha_error) b_error = self.body_error and render_error(self.body_error) return { 'form_author': sanitize(self.author), 'form_link': sanitize(self.link), 'form_captcha': sanitize(self.captcha), 'form_body': sanitize(self.body), 'form_author_error': a_error, 'form_link_error': l_error, 'form_captcha_error': c_error, 'form_body_error': b_error, 'form_action': self.action, 'form_method': self.method, } class Comment (object): def __init__(self, article, number, created = None): self.article = article self.number = number if created is None: self.created = datetime.datetime.now() else: self.created = created self.loaded = False # loaded on demand self._author = author self._link = '' self._raw_content = 'Removed comment' @property def author(self): if not self.loaded: self.load() return self._author @property def link(self): if not self.loaded: self.load() return self._link @property def raw_content(self): if not self.loaded: self.load() return self._raw_content def set(self, author, raw_content, link = '', created = None): self.loaded = True self._author = author self._raw_content = raw_content self._link = link self.created = created or datetime.datetime.now() def load(self): filename = os.path.join(comments_path, self.article.uuid, str(self.number)) try: raw = open(filename).readlines() except: return count = 0 for l in raw: if ':' in l: name, value = l.split(':', 1) if name.lower() == 'author': self._author = value.strip() elif name.lower() == 'link': self._link = value.strip() elif l == '\n': # end of header break count += 1 self._raw_content = ''.join(raw[count + 1:]) self.loaded = True def save(self): filename = os.path.join(comments_path, self.article.uuid, str(self.number)) try: f = open(filename, 'w') f.write('Author: %s\n' % self.author) f.write('Link: %s\n' % self.link) f.write('\n') f.write(self.raw_content) except: return def to_html(self): return rst_to_html(self.raw_content) def to_vars(self): return { 'number': self.number, 'author': sanitize(self.author), 'link': sanitize(self.link), 'date': self.created.isoformat(' '), 'created': self.created.isoformat(' '), 'year': self.created.year, 'month': self.created.month, 'day': self.created.day, 'hour': self.created.hour, 'minute': self.created.minute, 'second': self.created.second, } class CommentDB (object): def __init__(self, article): self.path = os.path.join(comments_path, article.uuid) # if comments were enabled after the article was added, we # will need to create the directory if not os.path.exists(self.path): os.mkdir(self.path, 0777) self.comments = [] self.load(article) def load(self, article): try: f = open(os.path.join(self.path, 'db')) except: return for l in f: # Each line has the following comma separated format: # number, created (epoch) # Empty lines are meaningful and represent removed # comments (so we can preserve the comment number) l = l.split(',') try: n = int(l[0]) d = datetime.datetime.fromtimestamp(float(l[1])) except: # Removed/invalid comment self.comments.append(None) continue self.comments.append(Comment(article, n, d)) def save(self): old_db = os.path.join(self.path, 'db') new_db = os.path.join(self.path, 'db.tmp') f = open(new_db, 'w') for c in self.comments: s = '' if c is not None: s = '' s += str(c.number) + ', ' s += str(time.mktime(c.created.timetuple())) s += '\n' f.write(s) f.close() os.rename(new_db, old_db) class Article (object): def __init__(self, path, created = None, updated = None): self.path = path self.created = created self.updated = updated self.uuid = "%08x" % zlib.crc32(self.path) self.loaded = False # loaded on demand self._title = 'Removed post' self._author = author self._tags = [] self._raw_content = '' self._comments = [] @property def title(self): if not self.loaded: self.load() return self._title @property def author(self): if not self.loaded: self.load() return self._author @property def tags(self): if not self.loaded: self.load() return self._tags @property def raw_content(self): if not self.loaded: self.load() return self._raw_content @property def comments(self): if not self.loaded: self.load() return self._comments def __eq__(self, other): if self.path == other.path: return True return False def add_comment(self, author, raw_content, link = ''): c = Comment(self, len(self.comments)) c.set(author, raw_content, link) self.comments.append(c) return c def load(self): # XXX this tweak is only needed for old DB format, where # article's paths started with a slash path = self.path if path.startswith('/'): path = path[1:] filename = os.path.join(data_path, path) try: raw = open(filename).readlines() except: return count = 0 for l in raw: if ':' in l: name, value = l.split(':', 1) if name.lower() == 'title': self._title = value.strip() elif name.lower() == 'author': self._author = value.strip() elif name.lower() == 'tags': ts = value.split(',') ts = [t.strip() for t in ts] self._tags = set(ts) elif l == '\n': # end of header break count += 1 self._raw_content = ''.join(raw[count + 1:]) db = CommentDB(self) self._comments = db.comments self.loaded = True def to_html(self): dirname = os.path.dirname post_url = '/'.join([dirname(full_url), 'posts', dirname(self.path)]) post_dir = '/'.join([data_path, dirname(self.path)]) rst = self.raw_content.replace('##POST_URL##', post_url) rst = rst.replace('##POST_DIR##', post_dir) # TODO: make it better! import re rst = re.sub(r'.. youtube:: (.*)', r'''.. raw:: html ''', rst) rst = re.sub(r'.. vimeo:: (\w*)', r'''.. raw:: html ''', rst) rst = re.sub(r'.. grooveshark:: (\w*)', r'''.. raw:: html Grooveshark is no more! This was supposed to show \1. ''', rst) return rst_to_html(rst) def to_vars(self): return { 'arttitle': sanitize(self.title), 'author': sanitize(self.author), 'date': self.created.isoformat(' '), 'uuid': self.uuid, 'tags': self.get_tags_links(), 'comments': len(self.comments), 'created': self.created.isoformat(' '), 'ciso': self.created.isoformat(), 'cyear': self.created.year, 'cmonth': self.created.month, 'cday': self.created.day, 'chour': self.created.hour, 'cminute': self.created.minute, 'csecond': self.created.second, 'updated': self.updated.isoformat(' '), 'uiso': self.updated.isoformat(), 'uyear': self.updated.year, 'umonth': self.updated.month, 'uday': self.updated.day, 'uhour': self.updated.hour, 'uminute': self.updated.minute, 'usecond': self.updated.second, } def get_tags_links(self): l = [] tags = list(self.tags) tags.sort() for t in tags: l.append('%s' % \ (blog_url, urllib.quote(t), sanitize(t) )) return ', '.join(l) class ArticleDB (object): def __init__(self, dbpath): self.dbpath = dbpath self.articles = [] self.uuids = {} self.actyears = set() self.actmonths = set() self.acttags = set() self.load() def get_articles(self, year = 0, month = 0, day = 0, tags = None): l = [] for a in self.articles: if year and a.created.year != year: continue if month and a.created.month != month: continue if day and a.created.day != day: continue if tags and not tags.issubset(a.tags): continue l.append(a) return l def get_article(self, uuid): return self.uuids[uuid] def load(self): try: f = open(self.dbpath) except: return for l in f: # Each line has the following comma separated format: # path (relative to data_path), \ # created (epoch), \ # updated (epoch) try: l = l.split(',') except: continue a = Article(l[0], datetime.datetime.fromtimestamp(float(l[1])), datetime.datetime.fromtimestamp(float(l[2]))) self.uuids[a.uuid] = a self.acttags.update(a.tags) self.actyears.add(a.created.year) self.actmonths.add((a.created.year, a.created.month)) self.articles.append(a) def save(self): f = open(self.dbpath + '.tmp', 'w') for a in self.articles: s = '' s += a.path + ', ' s += str(time.mktime(a.created.timetuple())) + ', ' s += str(time.mktime(a.updated.timetuple())) + '\n' f.write(s) f.close() os.rename(self.dbpath + '.tmp', self.dbpath) def get_year_links(self): yl = list(self.actyears) yl.sort(reverse = True) return [ '%d' % (blog_url, y, y) for y in yl ] def get_month_links(self, year): am = [ i[1] for i in self.actmonths if i[0] == year ] ml = [] for i in range(1, 13): name = calendar.month_name[i][:3] if i in am: s = '%s' % \ ( blog_url, year, i, name ) else: s = name ml.append(s) return ml def get_tag_links(self): tl = list(self.acttags) tl.sort() return [ '%s' % (blog_url, sanitize(t), sanitize(t)) for t in tl ] # # Main # def render_comments(article, template, form_data): print '' for c in article.comments: if c is None: continue print template.get_comment_header(c) print c.to_html() print template.get_comment_footer(c) if not form_data: form_data = CommentFormData() form_data.action = blog_url + '/comment/' + article.uuid + '#comment' captcha = captcha_method(article) print template.get_comment_form(article, form_data, captcha.puzzle) def render_html(articles, db, actyear = None, show_comments = False, redirect = None, form_data = None): if redirect: print 'Status: 303 See Other\r\n', print 'Location: %s\r\n' % redirect, print 'Content-type: text/html; charset=utf-8\r\n', print '\r\n', template = Templates(templates_path, db, actyear) print template.get_main_header() for a in articles: print template.get_article_header(a) print a.to_html() print template.get_article_footer(a) if show_comments: render_comments(a, template, form_data) print template.get_main_footer() def render_artlist(articles, db, actyear = None): template = Templates(templates_path, db, actyear) print 'Content-type: text/html; charset=utf-8\n' print template.get_main_header() print '

Articles

' for a in articles: print '
  • %(title)s
  • ' \ % { 'url': blog_url, 'uuid': a.uuid, 'title': a.title, 'author': a.author, } print template.get_main_footer() def render_atom(articles): if len(articles) > 0: updated = articles[0].updated.isoformat() else: updated = datetime.datetime.now().isoformat() print 'Content-type: application/atom+xml; charset=utf-8\n' print """ %(title)s %(url)s %(updated)sZ """ % { 'title': title, 'url': full_url, 'updated': updated, } for a in articles: vars = a.to_vars() vars.update( { 'url': full_url, 'contents': a.to_html(), } ) print """ %(arttitle)s %(author)s %(url)s/post/%(uuid)s %(arttitle)s %(ciso)sZ %(uiso)sZ
    %(contents)s
    """ % vars print "
    " def render_style(): print 'Content-type: text/css\r\n\r\n', print default_css # Get a dictionary with sort() arguments (key and reverse) by parsing the sort # specification format: # [+-]?? # Where "-" is used to specify reverse order, while "+" is regular, ascending, # order (reverse = False). The key value is an Article's attribute name (title, # author, created, updated and uuid are accepted), and will be used as key for # sorting. If a value is omitted, that value is taken from the default, which # should be provided using the same format specification, with the difference # that all values must be provided for the default. def get_sort_args(sort_str, default): def parse(s): d = dict() if not s: return d key = None if len(s) > 0: # accept ' ' as an alias of '+' since '+' is translated # to ' ' in URLs if s[0] in ('+', ' ', '-'): key = s[1:] d['reverse'] = (s[0] == '-') else: key = s if key in ('title', 'author', 'created', 'updated', 'uuid'): d['key'] = lambda a: getattr(a, key) return d args = parse(default) assert args['key'] is not None and args['reverse'] is not None args.update(parse(sort_str)) return args def handle_cgi(): import cgitb; cgitb.enable() form = cgi.FieldStorage() year = int(form.getfirst("year", 0)) month = int(form.getfirst("month", 0)) day = int(form.getfirst("day", 0)) tags = set(form.getlist("tag")) sort_str = form.getfirst("sort", None) uuid = None atom = False style = False post = False post_preview = False artlist = False comment = False if os.environ.has_key('PATH_INFO'): path_info = os.environ['PATH_INFO'] style = path_info == '/style' atom = path_info == '/atom' tag = path_info.startswith('/tag/') post = path_info.startswith('/post/') post_preview = path_info.startswith('/preview/post/') artlist = path_info.startswith('/list') comment = path_info.startswith('/comment/') and enable_comments if not style and not atom and not post and not post_preview \ and not tag and not comment and not artlist: date = path_info.split('/')[1:] try: if len(date) > 1 and date[0]: year = int(date[0]) if len(date) > 2 and date[1]: month = int(date[1]) if len(date) > 3 and date[2]: day = int(date[2]) except ValueError: pass elif post: uuid = path_info.replace('/post/', '') uuid = uuid.replace('/', '') elif post_preview: art_path = path_info.replace('/preview/post/', '') art_path = urllib.unquote_plus(art_path) art_path = os.path.join(data_path, art_path) art_path = os.path.realpath(art_path) common = os.path.commonprefix([data_path, art_path]) if common != data_path: # something nasty happened post_preview = False art_path = art_path[len(data_path)+1:] elif tag: t = path_info.replace('/tag/', '') t = t.replace('/', '') t = urllib.unquote_plus(t) tags = set((t,)) elif comment: uuid = path_info.replace('/comment/', '') uuid = uuid.replace('#comment', '') uuid = uuid.replace('/', '') author = form.getfirst('comformauthor', '') link = form.getfirst('comformlink', '') captcha = form.getfirst('comformcaptcha', '') body = form.getfirst('comformbody', '') db = ArticleDB(os.path.join(data_path, 'db')) if atom: articles = db.get_articles(tags = tags) articles.sort(**get_sort_args(sort_str, '-created')) render_atom(articles[:index_articles]) elif style: render_style() elif post: render_html( [db.get_article(uuid)], db, year, enable_comments ) elif post_preview: article = Article(art_path, datetime.datetime.now(), datetime.datetime.now()) render_html( [article], db, year, enable_comments ) elif artlist: articles = db.get_articles() articles.sort(**get_sort_args(sort_str, '+title')) render_artlist(articles, db) elif comment and enable_comments: form_data = CommentFormData(author.strip().replace('\n', ' '), link.strip().replace('\n', ' '), captcha, body.replace('\r', '')) article = db.get_article(uuid) captcha = captcha_method(article) redirect = False valid = True if not form_data.author: form_data.author_error = 'please, enter your name' valid = False if form_data.link: link = valid_link(form_data.link) if link: form_data.link = link else: form_data.link_error = 'please, enter a ' \ 'valid link' valid = False if not captcha.validate(form_data): form_data.captcha_error = captcha.help valid = False if not form_data.body: form_data.body_error = 'please, write a comment' valid = False else: error = validate_rst(form_data.body, secure=False) if error is not None: (line, desc, ctx) = error at = '' if line: at = ' at line %d' % line form_data.body_error = 'error%s: %s' \ % (at, desc) valid = False if valid: c = article.add_comment(form_data.author, form_data.body, form_data.link) c.save() cdb = CommentDB(article) cdb.comments = article.comments cdb.save() redirect = blog_url + '/post/' + uuid + '#comment-' \ + str(c.number) render_html( [article], db, year, enable_comments, redirect, form_data ) else: articles = db.get_articles(year, month, day, tags) articles.sort(**get_sort_args(sort_str, '-created')) if not year and not month and not day and not tags: articles = articles[:index_articles] render_html(articles, db, year) def usage(): print 'Usage: %s {add|rm|update} article_path' % sys.argv[0] def handle_cmd(): if len(sys.argv) != 3: usage() return 1 cmd = sys.argv[1] art_path = os.path.realpath(sys.argv[2]) if os.path.commonprefix([data_path, art_path]) != data_path: print "Error: article (%s) must be inside data_path (%s)" % \ (art_path, data_path) return 1 art_path = art_path[len(data_path)+1:] db_filename = os.path.join(data_path, 'db') if not os.path.isfile(db_filename): open(db_filename, 'w').write('') db = ArticleDB(db_filename) if cmd == 'add': article = Article(art_path, datetime.datetime.now(), datetime.datetime.now()) for a in db.articles: if a == article: print 'Error: article already exists' return 1 db.articles.append(article) db.save() if enable_comments: comment_dir = os.path.join(comments_path, article.uuid) try: os.mkdir(comment_dir, 0775) except OSError, e: if e.errno != errno.EEXIST: print "Error: can't create comments " \ "directory %s (%s)" \ % (comment_dir, e) # otherwise is probably a removed and re-added # article elif cmd == 'rm': article = Article(art_path) for a in db.articles: if a == article: break else: print "Error: no such article" return 1 if enable_comments: r = raw_input('Remove comments [y/N]? ') db.articles.remove(a) db.save() if enable_comments and r.lower() == 'y': shutil.rmtree(os.path.join(comments_path, a.uuid)) elif cmd == 'update': article = Article(art_path) for a in db.articles: if a == article: break else: print "Error: no such article" return 1 a.updated = datetime.datetime.now() db.save() else: usage() return 1 return 0 if os.environ.has_key('GATEWAY_INTERFACE'): i = datetime.datetime.now() handle_cgi() f = datetime.datetime.now() print '' % (f-i) else: sys.exit(handle_cmd())