author | Alberto Bertogli
<albertito@blitiri.com.ar> 2022-08-20 19:28:17 UTC |
committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2022-08-20 19:30:49 UTC |
parent | cd5c3f32c4f97278384638165f1bc71806640f68 |
blitiri.cgi | +930 | -897 |
diff --git a/blitiri.cgi b/blitiri.cgi index 3ca329f..69845ec 100755 --- a/blitiri.cgi +++ b/blitiri.cgi @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -#coding: utf8 +# coding: utf8 # blitiri - A single-file blog engine. # Alberto Bertogli (albertito@gmail.com) @@ -27,7 +27,7 @@ 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 = "/tmp/blog/cache" cache_path = None # URL to the blog, including the name. Can be a full URL or just the path. @@ -56,21 +56,25 @@ index_articles = 10 # Before rendering the rst, perform these regexp-based replacements. rst_regexp_replaces = [ - (r'.. youtube:: (.*)', - r'''.. raw:: html + ( + r".. youtube:: (.*)", + r""".. raw:: html <iframe width="560" height="315" src="https://www.youtube.com/embed/\1" frameborder="0" allowfullscreen></iframe> - '''), - (r'.. vimeo:: (\w*)', - r'''.. raw:: html + """, + ), + ( + r".. vimeo:: (\w*)", + r""".. raw:: html <iframe src="https://player.vimeo.com/video/\1" width="500" height="281" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe> - '''), + """, + ), ] # @@ -98,9 +102,9 @@ sys.path.append(os.getcwd()) # Load the config file, if there is one try: - from config import * + from config import * except: - pass + pass # Pimp *_path config variables to support relative paths @@ -121,9 +125,9 @@ templates_path = os.path.realpath(templates_path) # 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). +# 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 @@ -146,35 +150,38 @@ templates_path = os.path.realpath(templates_path) # 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 + +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, + "title": TitleCaptcha, } # If the configured captcha method was a known string, replace it by the @@ -182,7 +189,7 @@ known_captcha_methods = { # 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] + captcha_method = known_captcha_methods[captcha_method] # Default template @@ -494,639 +501,649 @@ div.section h1 { # 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 + # 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 + 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 + 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 as e: - desc = e.args[0].encode('utf-8') # the error string - desc = desc[9:] # remove "<string>:" - 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 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 as e: + desc = e.args[0].encode("utf-8") # the error string + desc = desc[9:] # remove "<string>:" + 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 + 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) + 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) + 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'] = '<a href="%s">%s</a>' \ - % (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, 0o777) - - 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) - for pattern, replacement in rst_regexp_replaces: - rst = re.sub(pattern, replacement, 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 = sorted(self.tags) - for t in tags: - l.append('<a class="tag" href="%s/tag/%s">%s</a>' % \ - (blog_url, urllib.parse.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 [ '<a href="%s/%d/">%d</a>' % (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 = '<a href="%s/%d/%d/">%s</a>' % \ - ( blog_url, year, i, name ) - else: - s = name - ml.append(s) - return ml - - def get_tag_links(self): - tl = sorted(self.acttags) - return [ '<a href="%s/tag/%s">%s</a>' % (blog_url, - sanitize(t), sanitize(t)) for t in tl ] + 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"] = '<a href="%s">%s</a>' % ( + 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, 0o777) + + 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) + for pattern, replacement in rst_regexp_replaces: + rst = re.sub(pattern, replacement, 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 = sorted(self.tags) + for t in tags: + l.append( + '<a class="tag" href="%s/tag/%s">%s</a>' + % (blog_url, urllib.parse.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 ['<a href="%s/%d/">%d</a>' % (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 = '<a href="%s/%d/%d/">%s</a>' % (blog_url, year, i, name) + else: + s = name + ml.append(s) + return ml + + def get_tag_links(self): + tl = sorted(self.acttags) + return [ + '<a href="%s/tag/%s">%s</a>' % (blog_url, sanitize(t), sanitize(t)) + for t in tl + ] + # # Main # + def render_comments(article, template, form_data): - print('<a name="comments" />') - 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', end=' ') - print('Location: %s\r\n' % redirect, end=' ') - print('Content-type: text/html; charset=utf-8\r\n', end=' ') - print('\r\n', end=' ') - 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('<h2>Articles</h2>') - for a in articles: - print('<li><a href="%(url)s/post/%(uuid)s">%(title)s</a></li>' \ - % { 'url': blog_url, - 'uuid': a.uuid, - 'title': a.title, - 'author': a.author, - }) - print(template.get_main_footer()) + print('<a name="comments" />') + 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", end=" ") + print("Location: %s\r\n" % redirect, end=" ") + print("Content-type: text/html; charset=utf-8\r\n", end=" ") + print("\r\n", end=" ") + 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("<h2>Articles</h2>") + for a in articles: + print( + '<li><a href="%(url)s/post/%(uuid)s">%(title)s</a></li>' + % { + "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() + 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("""<?xml version="1.0" encoding="utf-8"?> + print("Content-type: application/atom+xml; charset=utf-8\n") + print( + """<?xml version="1.0" encoding="utf-8"?> <feed xmlns="http://www.w3.org/2005/Atom"> <title>%(title)s</title> @@ -1135,19 +1152,24 @@ def render_atom(articles): <id>%(url)s</id> <!-- TODO: find a better <id>, see RFC 4151 --> <updated>%(updated)sZ</updated> - """ % { - '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(""" + """ + % { + "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( + """ <entry> <title>%(arttitle)s</title> <author><name>%(author)s</name></author> @@ -1162,13 +1184,16 @@ def render_atom(articles): </div> </content> </entry> - """ % vars) - print("</feed>") + """ + % vars + ) + print("</feed>") def render_style(): - print('Content-type: text/css\r\n\r\n', end=' ') - print(default_css) + print("Content-type: text/css\r\n\r\n", end=" ") + print(default_css) + # Get a dictionary with sort() arguments (key and reverse) by parsing the sort # specification format: @@ -1180,241 +1205,249 @@ def render_style(): # 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 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 'PATH_INFO' in os.environ: - 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.parse.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.parse.unquote_plus(t) - tags = {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) + 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 "PATH_INFO" in os.environ: + 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.parse.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.parse.unquote_plus(t) + tags = {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]) + 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, 0o775) - except OSError as 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 = 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 'GATEWAY_INTERFACE' in os.environ: - i = datetime.datetime.now() - handle_cgi() - f = datetime.datetime.now() - print('<!-- render time: %s -->' % (f-i)) + 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, 0o775) + except OSError as 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 = 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 "GATEWAY_INTERFACE" in os.environ: + i = datetime.datetime.now() + handle_cgi() + f = datetime.datetime.now() + print("<!-- render time: %s -->" % (f - i)) else: - sys.exit(handle_cmd()) - - + sys.exit(handle_cmd())