sitio personal de Rodrigo Garcia Saenz.

Consejo del día

Si tienes la posiblidad, haz donaciones a organizaciones sin ánimo de lucro para apoyar su trabajo.

Muchas de estas organizaciones se mantienen gracias a la gente que les apoya con donaciones monetarias.

Ver el codigo fuente


views.py

# -*- coding: utf-8 -*-
"""
Monomotapa - A Micro CMS
Copyright (C) 2014, Paul Munday.

PO Box 28228, Portland, OR, USA 97228
paul at paulmunday.net

Modificado por: Rodrigo Garcia 2017 https://rmgss.net/contacto

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero  Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.


Monomotapa:
    a city whose inhabitants are bounded by deep feelings of friendship, 
    so that they intuit one another's most secret needs and desire. 
    For instance, if one dreams that his friend is sad, the friend will
    perceive the distress and rush to the sleepers rescue.


    (Jean de La Fontaine, *Fables choisies, mises en vers*, VIII:11 Paris, 
    2nd ed., 1678-9)

cited in : 
Alberto Manguel and Gianni Guadalupi, *The Dictionary of Imaginary Places*, 
Bloomsbury, London, 1999.

A micro cms written using the Flask microframework, orignally to manage my 
personal site. It is designed so that publishing a page requires no more than
dropping a markdown page in the appropriate directory (though you need to edit
a json file if you want it to appear in the top navigation). 

It can also display its own source code and run its own unit tests.

The name 'monomotapa' was chosen more or less at random (it shares an initial
with me) as I didn't want to name it after the site and be typing import 
paulmunday, or something similar,  as that would be strange.

"""

from flask import render_template, abort, Markup, escape, request #, make_response
from flask import redirect
from werkzeug.utils import secure_filename

from pygments import highlight
from pygments.lexers import PythonLexer, HtmlDjangoLexer, TextLexer
from pygments.formatters import HtmlFormatter

import markdown

from time import gmtime, strptime, strftime, ctime, mktime
import datetime

import os.path
import os
import subprocess
import json
import traceback
from collections import OrderedDict

from simplemotds import SimpleMotd

from monomotapa import app
from monomotapa.config import ConfigError

from monomotapa.utils import captcha_comprobar_respuesta, captcha_pregunta_opciones_random
from monomotapa.utils import categorias_de_post, categoriasDePost, categoriasList, cabezaPost
from monomotapa.utils import titulo_legible, metaTagsAutomaticos

from markdown.extensions.toc import TocExtension

json_pattrs = {}
with open(os.path.join('monomotapa','pages.json'), 'r') as pagefile:
    json_pattrs = json.load(pagefile)

simplemotd = SimpleMotd("config_simplemotds.json")

class MonomotapaError(Exception):
    """create classs for own errors"""
    pass

def get_page_attributes(jsonfile):
    """Returns dictionary of page_attributes.
    Defines additional static page attributes loaded from json file.
    N.B. static pages do not need to have attributes defined there,
    it is sufficient to have a page.md in src for each /page 
    possible values are src (name of markdown file to be rendered)
    heading, title, and trusted (i.e. allow embeded html in markdown)"""
    try:
        with open(src_file(jsonfile), 'r') as pagesfile:
            page_attributes = json.load(pagesfile)
    except IOError:
        page_attributes = []
    return page_attributes

def get_page_attribute(attr_src, page, attribute):
    """returns attribute of page if it exists, else None.
    attr_src = dictionary(from get_page_attributes)"""
    if page in attr_src and attribute in attr_src[page]:
        return attr_src[page][attribute]
    else:
        return None
    

# Navigation
def top_navigation(page):
    """Generates navigation as an OrderedDict from navigation.json.
    Navigation.json consists of a json array(list) "nav_order"
    containing the names of the top navigation elements and 
    a json object(dict) called "nav_elements"
    if a page is to show up in the top navigation
    there must be an entry present in nav_order but there need not
    be one in nav_elements. However if there is the key must be the same.
    Possible values for nav_elements are link_text, url and urlfor
    The name  from nav_order will be used to set the link text, 
    unless link_text is present in nav_elements.
    url and urlfor are optional, however if ommited the url wil be
    generated in the navigation by  url_for('staticpage', page=[key])
    equivalent to  @app.route"/page"; def page())
    which may not be correct. If a url is supplied  it will be used 
    otherwise if urlfor is supplied it the url will be
    generated with url_for(urlfor). url takes precendence so it makes
    no sense to supply both.
    Web Sign-in is supported by adding a "rel": "me" attribute.
    """
    with open(src_file('navigation.json'), 'r') as  navfile:
        navigation = json.load(navfile)
    base_nav = OrderedDict({})
    for key in navigation["nav_order"]:
        nav = {}
        nav['base'] = key
        nav['link_text'] = key
        if key in navigation["nav_elements"]:
            elements = navigation["nav_elements"][key]
            nav.update(elements)
        base_nav[key] = nav
        
    return {'navigation' :  base_nav, 'page' : page}


# For pages
class Page:
    """Generates  pages as objects"""
    def __init__(self, page, **kwargs):
        """Define attributes for  pages (if present).
        Sets self.name, self.title, self.heading, self.trusted etc
        This is done through indirection so we can update the defaults 
        (defined  in the 'attributes' dictionary) with values from config.json
        or pages.json easily without lots of if else statements.
        If css is supplied it will overide any default css. To add additional
        style sheets on a per page basis specifiy them in pages.json.
        The same also applies with hlinks.
        css is used to set locally hosted stylesheets only. To specify 
        external stylesheets use hlinks: in config.json for 
        default values that will apply on all pages unless overidden, set here
        to override the default. Set in pages.json to add after default.
        """
        # set default attributes
        self.page = page.rstrip('/')
        self.defaults = get_page_attributes('defaults.json')
        self.pages = get_page_attributes('pages.json')
        self.url_base = self.defaults['url_base']
        title = titulo_legible(page.lower())
        heading = titulo_legible(page.capitalize())
        self.categorias = categoriasDePost(self.page)
        self.exclude_toc = True
        try:
            self.default_template = self.defaults['template']
        except KeyError:
            raise ConfigError('template not found in default.json')
        # will become self.name, self.title, self.heading, 
        # self.footer, self.internal_css, self.trusted
        attributes = {'name' : self.page, 'title' : title,
                      'navigation' : top_navigation(self.page),
                      'heading' : heading, 'footer' : None,
                      'css' : None , 'hlinks' :None, 'internal_css' : None,
                      'trusted': False,
                      'preview-chars': 250,
        }
        # contexto extra TODO: revisar otra forma de incluir un contexto
        self.contexto = {}
        self.contexto['consejo'] = simplemotd.getMotdContent()

        # set from defaults
        attributes.update(self.defaults)
        # override with kwargs
        attributes.update(kwargs)
        # override attributes if set in pages.json
        if page in self.pages:
            attributes.update(self.pages[page])
            # set attributes (as self.name etc)  using indirection
        for attribute, value in attributes.items():
            # print('attribute', attribute, '=-==>', value)
            setattr(self, attribute, value)
        # meta tags
        try:
            self.pages[self.page]['title'] = attributes['title']
            self.pages[self.page]['url_base'] = self.url_base
            metaTags = metaTagsAutomaticos(self.page, self.pages.get(self.page, {}))
            self.meta = metaTags
            # for key, value in self.pages[self.page].items():
            #     print(' ', key, ' = ', value)
        except Exception as e:
            tb = traceback.format_exc()
            print('Error assigning meta:', str(e), '\n', str(tb))

        # reset these as we want to append rather than overwrite if supplied
        if 'css' in kwargs:
            self.css = kwargs['css']
        elif 'css' in self.defaults:
            self.css = self.defaults['css']
        if 'hlinks' in kwargs:
            self.hlinks = kwargs['hlinks']
        elif 'hlinks' in self.defaults:
            self.hlinks = self.defaults['hlinks']
        # append hlinks and css from pages.json rather than overwriting
        # if css or hlinks are not supplied they are set to default
        if page in self.pages:
            if 'css' in self.pages[page]:
                self.css = self.css + self.pages[page]['css']
            if 'hlinks' in self.pages[page]:
                self.hlinks = self.hlinks + self.pages[page]['hlinks']
        # append heading to default if set in config
        self.title = self.title + app.config.get('default_title', '')

    def _get_markdown(self):
        """returns rendered markdown or 404 if source does not exist"""
        src = self.get_page_src(self.page, 'src', 'md') 
        if src is None:
            abort(404)
        else:
            return render_markdown(src, self.trusted)

    def get_page_src(self, page, directory=None, ext=None):
        """"return path of file (used to generate page) if it exists,
        or return none.
        Also returns the template used to render that page, defaults
        to static.html.
        It will optionally add an extension, to allow 
        specifiying pages by route."""
        # is it stored in a config
        pagename = get_page_attribute(self.pages, page, 'src')
        if not pagename:
            pagename = page + get_extension(ext)
        if os.path.exists(src_file(pagename , directory)):
            return src_file(pagename, directory)
        else:
            return None

    def get_template(self, page):
        """returns the template for the page"""
        pagetemplate = get_page_attribute(self.pages, page, 'template')
        if not pagetemplate:
            pagetemplate = self.default_template
        if os.path.exists(src_file(pagetemplate , 'templates')):
            return pagetemplate
        else:
            raise MonomotapaError("Template: %s not found" % pagetemplate)

    def generate_page(self, contents=None):
        """return a page generator function.
        For static pages written in Markdown under src/.
        contents are automatically rendered.
        N.B. See note above in about headers"""
        toc = '' # table of contents
        if not contents:
            contents, toc = self._get_markdown()
        # print('////', toc)
        template = self.get_template(self.page)
        # print('......................')
        # def mos(**kwargs):
        #     for k in kwargs:
        #         print(k, end=',')
        # mos(**vars(self))
        return render_template(template, 
                contents = Markup(contents),
                toc=toc,
                **vars(self))

# helper functions
def src_file(name, directory=None):
    """return potential path to file in this app"""
    if not directory:
        return os.path.join( 'monomotapa', name)
    else:
        return os.path.join('monomotapa', directory, name)


def get_extension(ext):
    '''constructs extension, adding or stripping leading . as needed.
    Return null string for None'''
    if ext is None:
        return ''
    elif ext[0] == '.':
        return ext
    else:
        return '.%s' % ext

def render_markdown(srcfile, trusted=False):
    """ Returns markdown file rendered as html and the table of contents as html. 
       Defaults to untrusted:
       html characters (and character entities) are escaped 
       so will not be rendered. This departs from markdown spec 
       which allows embedded html."""
    try:
        with open(srcfile, 'r') as f:
            src = f.read()
            md = markdown.Markdown(extensions=['toc', 'codehilite'])
            md.convert(src)
            toc = md.toc
            if trusted == True:
                content =  markdown.markdown(src,
                                             extensions=['codehilite',
                                                         TocExtension(permalink=True)])
            else:
                content = markdown.markdown(escape(src),
                                            extensions=['codehilite',
                                                        TocExtension(permalink=True)])
            return content, toc
    except IOError:
        return None

def render_pygments(srcfile, lexer_type):
    """returns src(file) marked up with pygments"""
    if lexer_type == 'python':
        with open(srcfile, 'r') as f:
            src = f.read()
            contents = highlight(src, PythonLexer(), HtmlFormatter())
    elif lexer_type == 'html':
        with open(srcfile, 'r') as f:
            src = f.read()
            contents = highlight(src, HtmlDjangoLexer(), HtmlFormatter())
    # default to TextLexer for everything else
    else:
        with open(srcfile, 'r') as f:
            src = f.read()
            contents = highlight(src, TextLexer(), HtmlFormatter())
    return contents

def get_pygments_css(style=None):
    """returns css for pygments, use as internal_css"""
    if style is None:
        style = 'friendly'
    return HtmlFormatter(style=style).get_style_defs('.highlight')


def heading(text, level):
    """return as html heading at h[level]"""
    heading_level = 'h%s' % str(level)
    return '\n<%s>%s</%s>\n' % (heading_level, text, heading_level)


def posts_list(ordenar_por_fecha=True, ordenar_por_nombre=False):
    '''Retorna una lista con los nombres de archivos con extension .md
    dentro de la cappeta src/posts, por defecto retorna una lista con
    la tupla (nombre_archivo, fecha_subida)'''
    lista_posts = []
    lp = []
    if ordenar_por_nombre:
        try:
            ow = os.walk("monomotapa/src/posts")
            p , directorios , archs = ow.__next__()
        except OSError:
            print ("[posts] - Error: Cant' os.walk() on monomotapa/src/posts except OSError")
        else:
            for arch in archs:
                if arch.endswith(".md") and not arch.startswith("#") \
                   and not arch.startswith("~") and not arch.startswith("."):
                    lista_posts.append(arch)
        lista_posts.sort()
        return lista_posts

    if ordenar_por_fecha:
        try:
            ow = os.walk("monomotapa/src/posts")
            p,d,files=ow.__next__()
        except OSError:
            print ("[posts] - Error: Can't os.walk() on monomotapa/src/posts except OSError.")
        else:
            for f in files:
                nombre_con_ruta = os.path.join("monomotapa/src/posts", f)
                if not f.endswith("~") and not f.startswith("#") and not f.startswith("."):
                    secs_modificacion = SecsModificacionPostDesdeJson(f, json_pattrs)
                    ultima_modificacion = os.path.getmtime(nombre_con_ruta)
                    lp.append((secs_modificacion, ultima_modificacion, f))
            lp.sort()
            lp.reverse()
            # colocando fecha en formato
            for tupla in lp:
                #fecha = strftime("a, %d %b %Y %H:%M:%S", ctime(tupla[0]))
                cfecha = ctime(tupla[1])
                #fecha = strptime("%a %b %d %H:%M:%S %Y", cfecha)
                lista_posts.append((cfecha, tupla[2]))

        return lista_posts

def categorias_list(categoria=None):
    """ Rotorna una lista con los nombres de posts y el numero de posts que
    pertenecen a la categoria dada o a cada categoria. 
    Las categorias se obtienen analizando la primera linea de cada archivo .md
    an la carpeta donde se almacenan los posts.
    
    Si no se especifica `categoria' cada elemento de la lista devuelta es:
    (nombre_categoria, numero_posts, [nombres_posts])

    si se especifica `categoria' cada elemento de la lista devuelta es:
    (numero_posts, [nombres_posts]
    
    """

    lista_posts = posts_list(ordenar_por_nombre=True)
    lista_categorias = []

    if categoria is not None:
        c = 0
        posts = []
        for post in lista_posts:
            nombre_arch = "monomotapa/src/posts/"+post
            with open(nombre_arch, 'r') as file:
                linea = file.readline().decode("utf-8")
                lc = linea.split("[#")[1:]
                for cad in lc:
                    cat = cad.split("]")[0]
                    if cat == categoria:
                        c += 1
                        posts.append(post)
        lista_categorias = (c, posts)
        return lista_categorias

    dic_categorias = {}
    for post in lista_posts:
        nombre_arch = "monomotapa/src/posts/"+post
        with open(nombre_arch, 'r') as fil:
            linea = fil.readline().decode('utf-8') # primera linea
            # extrayendo las categorias y registrando sus ocurrencias
            # ejemplo: catgorías: [#reflexión](categoria/reflexion) [#navidad](categoria/navidad)
            # extrae: [reflexion,navidad]
            lc = linea.split("[#")[1:]
            for cad in lc:
                cat = cad.split("]")[0]
                if cat not in dic_categorias:
                    dic_categorias[cat] = (1,[post]) # nuevo registro por categoria
                else:
                    tupla = dic_categorias[cat]
                    c = tupla[0] + 1
                    lis = tupla[1]
                    if post not in lis:
                        lis.append(post)
                    dic_categorias[cat] = (c, lis)
    # convirtiendo en lista
    for k, v in dic_categorias.iteritems():
        lista_categorias.append((k,v[0],v[1]))
    lista_categorias.sort()
    lista_categorias.reverse()
    return lista_categorias

def cabeza_post(archivo , max_caracteres=250, categorias=True):
    """ Devuelve las primeras lineas de una archivo de post (en formato markdown)
    con un maximo numero de caracteres excluyendo titulos en la cabeza devuelta.

    Si se especifica `categorias' en True
    Se devuelve una lista de la forma:
    (cabeza_post, categorias)
    donde categorias son cadenas con los nombres de las categorias a la que
    pertenece el post
    """
    cabeza_post = ""
    cats = []
    with open(os.path.join("monomotapa/src/posts",archivo)) as file:
        # analizando si hay titulos al principio
        # Se su pone que la primera linea es de categorias
        for linea in file.readlines():
            linea = linea.decode("utf-8")
            if linea.startswith(u"categorías:") or linea.startswith("categorias"):
                if categorias:
                    cats = categoriasDePost(archivo)
                    #cats = categorias_de_post(archivo)
            else:
                # evitando h1, h2
                if linea.startswith("##") or linea.startswith("#"):
                    cabeza_post += " "
                else:
                    cabeza_post += linea
            if len(cabeza_post) >= max_caracteres:
                break
        cabeza_post = cabeza_post[0:max_caracteres-1]
    if categorias:
        return (cabeza_post, cats)
    return cabeza_post

def ultima_modificacion_archivo(archivo):
    """ Retorna una cadena indicando la fecha de ultima modificacion del 
    `archivo' dado, se asume que `archivo' esta dentro la carpeta "monomotapa/src"
    Retorna una cadena vacia en caso de no poder abrir `archivo'
    """
    try:
        ts = strptime(ctime(os.path.getmtime("monomotapa/src/"+archivo+".md")))
        return strftime("%d %B %Y", ts)
    except OSError:
        return ""

def SecsModificacionPostDesdeJson(archivo, dict_json):
    ''' dado el post con nombre 'archivo' busca en 'dict_json' el
    attribute 'date' y luego obtiene los segundos totales desde
    esa fecha.
    Si no encuentra 'date' para 'archivo' en 'dict.json'
    retorna los segundos totales desde la ultima modificacion
    del archivo de post directamente (usa os.path.getmtime)
    '''
    nombre = archivo.split('.md')[0] # no contar extension .md
    nombre_con_ruta = os.path.join("monomotapa/src/posts", archivo)
    date_str = dict_json.get('posts/'+nombre, {}).\
      get('attributes',{}).\
      get('date','')
    if date_str == '':
        # el post no tiene "date" en pages.json
        return os.path.getmtime(nombre_con_ruta)
    else:
        time_struct = strptime(date_str, '%Y-%m-%d')
        dt = datetime.datetime.fromtimestamp(mktime(time_struct))
        return (dt - datetime.datetime(1970,1,1)).total_seconds()
    
def noticias_recientes(cantidad=11, max_caracteres=250,
                       categoria=None, pagina=0):
    '''Devuelve una lista con hasta `cantidad' de posts mas recientes, 
    un maximo de `max_caracteres' de caracteres del principio del post y
    el numero total de posts encontrados

    Si se proporciona `categoria' devuelve la lista de posts solamente 
    pertenecientes esa categoria.

    Si `pagina' > 0 se devulve hasta `cantidad' numero de posts en el
    rango de [ cantidad*pagina : cantidad*(pagina+1)]

    Cada elemento de la lista devuelta contiene:
    (nombre_post, ultima_modificacion, cabeza_archivo, categorias)
    
    Al final se retorna: (lista_posts, numero_de_posts)
    '''
    lista_posts = []
    lp = []
    num_posts = 0
    
    posts_en_categoria = []
    if categoria is not None:
        #posts_en_categoria = categorias_list(categoria)[1]
        posts_en_categoria = categoriasList(categoria)[1]
        # categoria especial fotos
        if categoria == "fotos":
            l = []
            for p in posts_en_categoria:
                l.append(p + '.md')
            posts_en_categoria = l
    try:
        ow = os.walk("monomotapa/src/posts")
        p,d,files = ow.__next__()
        #p,d,files=ow.next()
    except OSError:
        print ("[posts] - Error: Can't os.walk() on monomotapa/src/posts except OSError.")
    else:
        for f in files:
            nombre_con_ruta = os.path.join("monomotapa/src/posts", f)
            if not f.endswith("~") and not f.startswith("#") and not f.startswith("."):
                secs_modificacion = SecsModificacionPostDesdeJson(f, json_pattrs)
                ultima_modificacion = os.path.getmtime(nombre_con_ruta)
                previewChars = json_pattrs.get('posts/'+f[:-3], {}).\
                    get('attributes', {}).\
                    get('preview-chars', max_caracteres)

                if categoria is not None:
                    if f in posts_en_categoria:
                        lp.append((secs_modificacion,
                                   ultima_modificacion,
                                   previewChars,
                                   f))
                        num_posts += 1
                else:
                    lp.append((secs_modificacion,
                               ultima_modificacion,
                               previewChars,
                               f))
                    num_posts += 1
        lp.sort()
        lp.reverse()
        # seleccionado por paginas
        lp = lp[cantidad*pagina : cantidad*(pagina+1)]

        # colocando fecha en formato
        for tupla in lp:
            cfecha = ctime(tupla[1])
            nombre_post = tupla[3].split(os.sep)[-1]
            previewChars = tupla[2]
            #contenido = cabeza_post(tupla[3], max_caracteres=previewChars)[0]
            #categorias = cabeza_post(tupla[3], max_caracteres=previewChars)[1]
            contenido = cabezaPost(tupla[3], max_caracteres=previewChars)[0]
            categorias = cabezaPost(tupla[3], max_caracteres=previewChars)[1]
            cabeza_archivo = markdown.markdown(escape(contenido + ' ...'))
            lista_posts.append((nombre_post[:-3], cfecha, \
                                cabeza_archivo, categorias))

        return (lista_posts, num_posts)

def noticias_relacionadas(cantidad=5, nombre=None):
    """Retorna una lista con posts relacionadas, es decir que tienen son de las
    mismas categorias que el post con nombre `nombre'.

    Cada elemento de la lista de posts contiene el nombre del post
    """
    #categorias = categorias_de_post(nombre) ## TODO: corregir categorias de post
    categorias = categoriasDePost(nombre)
    numero = 0
    if categorias is None:
        return None
    posts = []
    for categoria in categorias:
        #lista = categorias_list(categoria)[1] # nombres de posts
        lista = categoriasList(categoria)[1]
        numero += len(lista)
        for nombre_post in lista:
            if nombre_post + '.md' != nombre:
                posts.append(nombre_post)
        if numero >= cantidad:
            return posts
    return posts

def rss_ultimos_posts_jinja(cantidad=15):
    """Retorna una lista de los ultimos posts preparados para 
    ser renderizados (usando jinja) como un feed rss

    Examina cada post del mas reciente al menos reciente, en
    total `cantidad' posts. Por cada post devuelve:
    
    id: id which identifies the entry using a
    universally unique and permanent URI
    
    author: Get or set autor data. An author element is a dict containing a
    name, an email adress and a uri.

    category:  A categories has the following fields:
    - *term* identifies the category
    - *scheme* identifies the categorization scheme via a URI.
    - *label* provides a human-readable label for display

    comments: Get or set the the value of comments which is the url of the
    comments page for the item.
    
    content: Get or set the cntent of the entry which contains or links to the
    complete content of the entry.
    
    description(no contiene): Get or set the description value which is the item synopsis.
    Description is an RSS only element.
    
    link: Get or set link data. An link element is a dict with the fields
    href, rel, type, hreflang, title, and length. Href is mandatory for
    ATOM.

    pubdate(no contiene): Get or set the pubDate of the entry which indicates when the entry
    was published.

    title: the title value of the entry. It should contain a human
    readable title for the entry.

    updated: the updated value which indicates the last time the entry
    was modified in a significant way.
    """
    lista_posts = []
    lp = []
    num_posts = 0
    
    try:
        ow = os.walk("monomotapa/src/posts")
        p,d,files=ow.__next__()
    except OSError:
        print ("[posts] - Error: Can't os.walk() on monomotapa/src/posts except OSError.")
    else:
        for f in files:
            nombre_con_ruta = os.path.join("monomotapa/src/posts", f)
            if not f.endswith("~") and not f.startswith("#") and not f.startswith("."):
                lp.append((os.path.getmtime(nombre_con_ruta), f))
                num_posts += 1
            if num_posts > cantidad:
                break
        lp.sort()
        lp.reverse()
        # colocando fecha en formato
        for tupla in lp:
            nombre_post = tupla[1].split(os.sep)[-1]
            #contenido = cabeza_post(tupla[1], max_caracteres=149999)
            contenido = cabezaPost(tupla[1], max_caracteres=149999)
            id_post = "https://rmgss.net/posts/"+nombre_post[:-3]
            #categorias = categorias_de_post(nombre_post)
            categorias = categoriasDePost(nombre_post)
            dict_categorias = {}
            c = ""
            for cat in categorias:
                c += cat + " "
            dict_categorias['label'] = c
            #dict_categorias['term'] = c
            html =  markdown.markdown(escape(contenido))
            link = id_post
            pubdate = ctime(tupla[0])
            title = titulo_legible(nombre_post[:-3]) # no incluir '.md'
            updated = pubdate
            
            dict_feed_post = {
                "id":id_post,
                "author": "Rodrigo Garcia",
                "category" : categorias,
                "content": html,
                "link" : id_post,
                "updated" : updated,
                "title": title
                }
            lista_posts.append(dict_feed_post)
    return lista_posts

###### Define routes

@app.errorhandler(404)
def page_not_found(e):
    """ provides basic 404 page"""
    defaults = get_page_attributes('defaults.json')
    try:
        css = defaults['css']
    except KeyError:
        css = None
    pages = get_page_attributes('pages.json')
    if '404' in pages:
        if'css' in pages['404']:
            css = pages['404']['css']
    return render_template('static.html', 
            title = "404::page not found", heading = "Page Not Found", 
            navigation = top_navigation('404'),
            css = css,
            contents = Markup(
                "This page is not there, try somewhere else.")), 404

@app.route('/users/', defaults={'page': 1})
@app.route('/users/page/<int:page>')

@app.route("/", defaults={'pagina':0})
@app.route('/<int:pagina>')
def index(pagina):
    """provides index page"""
    index_page = Page('index')
    
    lista_posts_recientes, total_posts = noticias_recientes(pagina=pagina)
    index_page.contexto['lista_posts_recientes'] = lista_posts_recientes
    index_page.contexto['total_posts'] = total_posts
    index_page.contexto['pagina_actual'] = int(pagina)
    return index_page.generate_page()

# default route is it doe not exist elsewhere
@app.route("/<path:page>")
def staticpage(page):
    """ display a static page rendered from markdown in src
    i.e. displays /page or /page/ as long as src/page.md exists.
    srcfile, title and heading may be set in the pages global 
    (ordered) dictionary but are not required"""
    static_page = Page(page)
    return static_page.generate_page()

@app.route("/posts/<page>")
def rposts(page):
    """ Mustra las paginas dentro la carpeta posts, no es coincidencia 
    que en este ultimo directorio se guarden los posts.
    Ademas incrusta en el diccionario de contexto de la pagina la 
    fecha de ultima modificacion del post
    """
    static_page = Page("posts/"+page)
    ultima_modificacion = ultima_modificacion_archivo("posts/"+page)
    static_page.contexto['relacionadas'] = noticias_relacionadas(nombre=page+".md")
    static_page.contexto['ultima_modificacion'] = ultima_modificacion
    static_page.exclude_toc = False # no excluir Índice de contenidos
    return static_page.generate_page()

@app.route("/posts")
def indice_posts():
    """ Muestra una lista de todos los posts
    """
    lista_posts_fecha = posts_list()
    #lista_posts_categoria = categorias_list()
    lista_posts_categoria = categoriasList()
    
    static_page = Page("posts")
    static_page.contexto['lista_posts_fecha'] = lista_posts_fecha
    static_page.contexto['lista_posts_categoria'] = lista_posts_categoria
    return static_page.generate_page()

@app.route("/posts/categorias")
def lista_categorias():
    """ Muestra una lista de las categorias , los posts pertenecen
    a cada una y un conteo"""
    #lista_categorias = categorias_list()
    lista_categorias = categoriasList()
    
    static_page = Page("categorias")
    static_page.contexto['lista_posts_categoria'] = lista_categorias
    #return (str(lista_categorias))
    return static_page.generate_page()

@app.route("/posts/categoria/<categoria>")
def posts_de_categoria(categoria):
    """ Muestra los posts que perteneces a la categoria dada
    """
    lista_posts = []

    if categoria == "fotos": # caegoria especial fotos
        lista_posts, total_posts = noticias_recientes(max_caracteres=1250,categoria=categoria)
        static_page = Page("fotos")
        static_page.contexto['categoria_actual'] = categoria
        static_page.contexto['lista_posts_recientes'] = lista_posts
        return static_page.generate_page()

    #lista_posts = categorias_list(categoria=categoria)
    lista_posts = categoriasList(categoria=categoria)
    static_page = Page("categorias")
    static_page.contexto['categoria_actual'] = categoria
    static_page.contexto['lista_posts_categoria'] = lista_posts
    return static_page.generate_page()

@app.route("/posts/recientes", defaults={'pagina':0})
@app.route("/posts/recientes/<int:pagina>")
def posts_recientes(pagina):
    """ muestra una lista de los posts mas recientes
    TODO: terminar
    """
    lista_posts, total_posts = noticias_recientes(max_caracteres=368,
                                                  pagina=pagina)

    static_page = Page("recientes")
    static_page.contexto['lista_posts_recientes'] = lista_posts
    static_page.contexto['total_posts'] = total_posts
    static_page.contexto['pagina_actual'] = pagina
    
    #return (str(lista_posts))
    return static_page.generate_page()

@app.route("/contacto", methods=['GET'])
def contacto():
    tupla_captcha = captcha_pregunta_opciones_random()
    if tupla_captcha is None:
        return ("<br>Parece un error interno!</br>")
    pregunta = tupla_captcha[0]
    opciones = tupla_captcha[1]
    static_page = Page("contacto")
    static_page.contexto['pregunta'] = pregunta
    static_page.contexto['opciones'] = opciones
    
    return static_page.generate_page()

@app.route("/contactoe", methods=['POST'])
def comprobar_mensaje():
    """ Comprueba que el mensaje enviado por la caja de texto sea valido
    y si lo es, guarda un archivo de texto con los detalles"""
    errors = []
    if request.method == "POST":
        # comprobando validez
        nombre = request.form["nombre"]
        dir_respuesta = request.form['dir_respuesta']
        mensaje = request.form['mensaje']

        pregunta = request.form['pregunta']
        respuesta = request.form['respuesta']

        if len(mensaje) < 2 or mensaje.startswith("   "):
            errors.append("Mensaje invalido")
            
        if not captcha_comprobar_respuesta(pregunta, respuesta):
            errors.append("Captcha invalido")

        if len(errors) > 0:
            return str(errors)
        # guardando texto
        texto = "Remitente: "+nombre
        texto += "\nResponder_a: "+dir_respuesta
        texto += "\n--- mensaje ---\n"
        texto += mensaje
        
        # TODO: cambiar a direccion especificada en archivo de configuracion
        dt = datetime.datetime.now()
        nombre = "m_"+str(dt.day)+"_"+str(dt.month)+\
                 "_"+str(dt.year)+"-"+str(dt.hour)+\
                 "-"+str(dt.minute)+"-"+str(dt.second)
        with open(os.path.join("fbs",nombre), "wb") as f:
            f.write(texto.encode("utf-8"))
        return redirect("/mensaje_enviado", code=302)

@app.route("/mensaje_enviado")
def mensaje_enviado():
    static_page = Page("mensaje_enviado")
    return static_page.generate_page()

@app.route("/rss")
def rss_feed():
    """Genera la cadena rss con las 15 ultimas noticias del sitio
    TODO: Agregar mecenismo para no generar los rss feeds y solo
    devolver el archivo rss.xml generado anteriormente. Esto
    quiere decir solamente generar el rss_feed cuando se haya hecho
    un actualizacion en los posts mas reciente que la ultima vez
    que se genero el rss_feed
    """
    #return str(rss_ultimos_posts_jinja())
    return render_template("rss.html", 
                           contents = rss_ultimos_posts_jinja())
    #**vars(self)
    #)

##### specialized pages
@app.route("/source")
def source():
    """Display source files used to render a page"""
    source_page = Page('source', title = "view the source code", 
            #heading = "Ver el código fuente",
                       heading = "Ver el codigo fuente",
            internal_css = get_pygments_css())
    page = request.args.get('page')
    # get source for markdown if any. 404's for non-existant markdown
    # unless special page eg source
    pagesrc = source_page.get_page_src(page, 'src', 'md')
    special_pages = ['source', 'unit-tests', '404']
    if not page in special_pages and pagesrc is None:
        abort(404)
    # set enable_unit_tests  to true  in config.json to allow 
    #  unit tests to be run  through the source page
    if app.config['enable_unit_tests']:
        contents = '''<p><a href="/unit-tests" class="button">Run unit tests
    </a></p>'''
        # render tests.py if needed
        if page == 'unit-tests':
            contents += heading('tests.py', 2)
            contents += render_pygments('tests.py', 'python')
    else:
        contents = ''
    # render views.py
    contents += heading('views.py', 2)
    contents += render_pygments(source_page.get_page_src('views.py'), 
            'python')
    # render markdown if present
    if pagesrc:
        contents += heading(os.path.basename(pagesrc), 2)
        contents += render_pygments(pagesrc, 'markdown')
    # render jinja templates
    contents += heading('base.html', 2)
    contents += render_pygments(
        source_page.get_page_src('base.html', 'templates'), 'html')
    template = source_page.get_template(page)
    contents += heading(template, 2)
    contents += render_pygments(
        source_page.get_page_src(template, 'templates'), 'html')
    return source_page.generate_page(contents)

# @app.route("/unit-tests")
# def unit_tests():
#     """display results of unit tests"""
#     unittests = Page('unit-tests', heading = "Test Results", 
#             internal_css = get_pygments_css())
#     # exec unit tests in subprocess, capturing stderr
#     capture = subprocess.Popen(["python", "tests.py"], 
#             stdout = subprocess.PIPE, stderr = subprocess.PIPE)
#     output = capture.communicate()
#     results = output[1]
#     contents = '''<p>
#     <a href="/unit-tests" class="button">Run unit tests</a>
#     </p><br>\n
#     <div class="output" style="background-color:'''
#     if 'OK' in results:
#         color = "#ddffdd"
#         result = "TESTS PASSED"
#     else:
#         color = "#ffaaaa"
#         result = "TESTS FAILING"
#     contents += ('''%s">\n<strong>%s</strong>\n<pre>%s</pre>\n</div>\n'''
#             % (color, result, results))
#     # render test.py 
#     contents += heading('tests.py', 2)
#     contents += render_pygments('tests.py', 'python')
#     return unittests.generate_page(contents)

nodo-chersky-LaOtraRed.md

Esta es una reseña sobre la puesta en marcha  **experimental** del nodo "Chersky" como parte del proyecto [LaOtraRed La Paz](https://lapaz.laotrared.net). En este post podrás revisar fotos, experiencias sobre el montado del nodo, además detalles técnicos de puesta de servicios y configuraciones.

**Nota:** Este artículo es una referencia histórica por que el nodo Chersky  ha evolucionado, aquí [la segunda versión](/posts/renovando-nodo-Chersky-parte-1).
## Nodo Chersky - LaOtraRed ##

* [LaOtraRed](#LaOtraRed)
* [¿De qué se trata?](#acercade)
* [Materiales usados](#materiales)
* [Montado de la torre](#torre)
* [Servicios disponibles](#servicios)
* [Agradecimientos y más](#agradecimientos)
* [Infraestructura de red del nodo](#infraestructura)
* [Configuraciones de aparatos enrutadores](#configuraciones)

<span id="LaOtraRed"></span>
### Sobre LaOtraRed ###

[LaOtraRed La Paz](https://lapaz.laotrared.net) es una red libre construida y mantenida enteramente por personas voluntarias. La red le pertenece a toda la comunidad que forma parte de ella y no existe una persona, organización u empresa que sea dueña.

Para que esta sea una red libre se siguen cuatro principios de convivencia fundamentales:

* Eres libre de utilizar la red para cualquier propósito en tanto no perjudiques el funcionamiento de la propia red ni a la libertad de los demás usuarios.
* Eres libre de saber cómo es la red, de qué se compone y cómo funciona.
* Eres libre de emplear la red para cualquier tipo de comunicación y difundir su funcionamiento.
* Incorporándote a la red, ayudas a extender estas libertades en las mismas condiciones.

Estos principios son extraídos de la licencia [procomún inalámbrica version 1.0 derivada (Bolivia)](https://laotrared.net).

En La Paz LaOtraRed es principalmente impulsada por el [Grupo de estudio de redes libres GEReL](https://wiki.lapaz.laotrared.net/gerel), del cual soy parte activa.

El nodo Chersky es el segundo nodo asegurado después del nodo [sopocachi I](https://serendipias.enmaskarado.com/2016/11/red-libre-1er-nodo-abierto/).

<span id="acercade"></span>
### ¿De qué se trata?  ###

Este nodo es un punto de **conexión wifi libre** en un barrio de la ciudad de La Paz, si alguién se conecta a la señal wifi puede utilizar una serie de [servicios](#servicios) libremente. Además este punto de conexión wifi está preparado para actuar como nodo en la red distribuida de LaOtraRed y se clasifica como un [nodo normal](https://wiki.lapaz.laotrared.net/redlibre/tipos_de_conexiones#nodos_normales) por repartir la señal de la red distribuida en 5 Ghz y 2.4 Ghz.

Este un diagrama a grandes rasgos de la [infraestructura](#infraestructura) del nodo que iré detallando más adelante.

![Nodo Chersky infraestructura](../static/imgs/posts/nodo_chersky.png)

#### ubicación ####

El nodo se encuentra en la ciudad de La Paz, casi llegando al barrio gráfico cerca la plaza Villaroel y el hospital Arco Iris, puedes buscar la señal de wifi por ahí.

<span id="materiales"></span>
### Materiales usados ###

He realizado una inversión considerable en el montado de este nodo, pero esto no quiere decir que esta es la forma óptima de montar un nodo o que el precio de montado es irreducible, como referencia voy a poner los precios de los materiales y equipos comprados:

*Equipos de comuptación y redes*

* Computador AMD Atlon (prestado del r00thouse, a este server lo llamamos [chipsito](#chipsito))
* Pila BIOS CR2032 (15 bs)
* Router Dual Band modelo TLWDR3600 (500 bs)
* Swtich ethernet (reciclado)

*Accesorios redes*

* 2 Cajas de red (30 bs)
* Conectores hembra y macho jack (10 bs)
* 5 Conectores rj45 (5bs)
* 20 metros de cable utp cat5 (50 bs)

*Preparado de la antena*

* 6 metros cable coaxial rg58 para hacer antena casera (24 bs)
* 2 conectores sma macho (28 bs)
* 1 conector sma hembra (14 bs)
* Tubo pvc 1.3 m (reciclado)

*Montado de la torre*

* 2 amarros de alambre de amarre (20 bs)
* 2 palos de fierro 10 cm (reciclado)
* Pedazos de madera pequeños (reciclados de un botadero)

**Inversión adquiriendo materiales y equipos :** `696 bs`

<span id="torre"></span>
### Montado de la "torre" ###

Para la "torre" he usado básicamente los palos de fierro, las maderas, alambre de amarre y cables de red.

He puesto el router en el techo para disminuir la distancia entre el equipo y la antena así se puede reducir la pérdida en la línea de transmisión coaxial hasta la antena. Y para colocar el router arriba se requiere darle suministro eléctrico, lo que en ciscunstancias normales significa llevar un cable con 220 V AC.

#### Cajitas de red modificadas para ahorrarse cable de 220 V AC ####

Para ahorrarse el cable para 220 V AC en el techo y aprovechar que no se necesitan todos los hilos del cable para la conexión ethernet, se ha hecho una modificación usando dos cajitas de red, y dos conectores jack. Esta modificación [está mejor descrita aquí](https://wiki.hacklab.org.bo/wiki/Agregando_suministro_de_energ%C3%ADa_en_cable_UTP), estas son un par de fotos del trabajo hecho.

![cajitas abiertas](../static/imgs/posts/cajitas_abiertas.jpg)

<img src="../static/imgs/posts/cajitas_terminadas.jpg">

#### Construcción de antena casera ####

Busqué la forma de hacer una antena casera onmidireccional para wifi con ganancia por encima de los 15 DBi, principalmente para ahorrarme dinero al tratar de adquirir una antena. Encontré [este diseño de antena](http://www.nodomainname.co.uk/Omnicolinear/2-4collinear.htm) hecha con el mismo cable coaxial y como parecía sencilla de hacer la hice.

Pero no obtuve los resultados deseados y probablemente por errores en el proceso de construcción, por ejemplo en las medidas de los trozos o al preparar los conectores, al final la antena medía cerca de 1.4 m y la ganancia era similar a la antena con la que venía el router. De todas formas terminé poníendola en el techo y espero poder realizar una mejor antena. Estaría muy agradecido si me das sugerencias sobre antenas onmidireccionales, [hazlo por aquí por favor](/contacto) o escribe al [foro de la otra red La Paz](https://foro.laotrared.net) muchos te lo agradercerán.

Aquí algunas fotos de lo que hice.

Preparando los pedazos.

![p1](../static/imgs/posts/antena_prep1.jpg)
![p2](../static/imgs/posts/antena_prep2.jpg)
![p3](../static/imgs/posts/antena_prep3.jpg)
![p5](../static/imgs/posts/antena_prep5.jpg)

Colocando el dentro el tubo pvc como cubierta, a un extremo de la antena puse un conector SMA hembra para que se pueda conectar a otros pigtails de cable coaxial.
![p6](../static/imgs/posts/antena_prep6.jpg)

Conectado al router luego de conectar con un pigtail que hice con el mismo cable coaxial y los conectores SMA macho.

![p4](../static/imgs/posts/antena_prep4.jpg)

#### En el techo ####

Usando estos materiales:

![torre materiales](../static/imgs/posts/torre_materiales.jpg)

Prepare una base con los dos fierros e "incrusté" un fierro en la base de madera para tener mejor apoyo sobre el techo inclinado.

![torre materiales](../static/imgs/posts/base.jpg)

Luego amarré 3 alambres para sujetar los fierros donde también aseguré la antena con el alambre de amarre, al final la base de la "torre" terminó así:

![torre asegurada](../static/imgs/posts/torre_asegurada.jpg)

Después conecté el cable coaxial de la antena casera a la entrada del router, en la imagen siguiente no esta el cable utp que va de la cajita al router, lo dibuje en la imagen por que después si lo puse o no habría conexión con el servidor.

![torre asegurada](../static/imgs/posts/router_techo.jpg)

Una vez cubierto el router para protegerlo de la interperie y asegurarlo, sólo queda el trabajo al interior y esto es acomodar la computadora y conectar al switch tal y como si fuera una red LAN.

<span id="chipsito"></span>
##### Servidor "chipsito" para dar servicios ####

Este servidor es del r00thouse y agradezco me lo presten mientras no lo necesiten. La  historia que me contarón es que **Amos Batto** un activista ambientalista y que antes se pasaba por el r00thouse donó el equipo, agradezco a Amos por la donación y aunque chipsito es un equipo muy pesado y para algunos antiguo, funciona muy bien, espero darle el mejor uso en lo que le queda de vida útil.

Chipsito tiene una CPU AMD Athlon, con 784 MB de RAM y 60 GB de disco duro. Luego de comprarle una nueva pila para el BIOS funciona bien. Le instalé Debian GNU/Linux 8 jessie y puse algunos servicios que especificaré a continuación.

Tiene el nombre de **Chipsito** gracias a **Chip** el perro de Armin que vive en el r00thouse y te daba la bienvenida con ladridos y golpes con las patas delanteras... es que es muy jugetón e hiperactivo. Además había otro equipo en el [r00thouse](https://hacklab.org.bo) que llamamos Chip pero tenía características superiores al chipsito. Ni hablar más aquí unas fotos del equipo.

![chipsito1](../static/imgs/posts/chipsito1.jpg)
![chipsito2](../static/imgs/posts/chipsito2.jpg)

<span id="servicios"></span>
### Servicios disponibles ###

Hasta el momento hay 6 servicios principales.

* [wikipedia offline](https://wiki.lapaz.laotrared.net/servicios/wikipedia_offline) en castellano.
* Streaming de audio con icecast2, ices e ices2.
* Imageboard basado en [https://github.com/tslocum/TinyIB](https://github.com/tslocum/TinyIB)
* [El botadero](http://botadero.rmgss.net) para compartir archivos.
* Una app para poner anuncios y comentarios.
* Un directorio de descargas de archivos variados.

Más referencia sobre el despliegue de estos servicios [https://wiki.lapaz.laotrared.net/guias/indice_servicios](https://wiki.lapaz.laotrared.net/guias/indice_servicios).

A parte en el router he tenido que configurar dnsmasq como servidor dns "invasivo" lo que se describirá más adelante.

<img src="../static/imgs/posts/pag_principal1.jpg" width=350>

<small>Arriba una captura de pantalla del los servicios en la página principal del nodo.</small>

<span id="agradecimientos"></span>
### Agradecimientos y más ###

Muchas personas han hecho posible el montado de este nodo y del proyecto LaOtraRed, voy a listar a quienes han tenido especial influencia.

* **Luis Mita:** Su ayuda en los aspectos técnicos han sido fundamentales, cuando andaba desubicado me ayudaba a ubicarme de nuevo. Su apoyo moral también ha sido importante además de ser el impulsor principal del proyecto LaOtraRed en La Paz.
* **Donato Aymatha:** Ha hecho un gran trabajo consiguiendo contactos y difundiendo el proyecto a distintos sectores y eso me ha ayudado a entender algunas necesidades comunicacionales básicas de muchas personas y que no todo se enfrasca en un mundo de consumismo digital, hay que darle a las personas los medios para generar sus propios contenidos y en una red libre eso es importante.
* **Sergio Guillén:** Wonkey como miembro del r00thouse ayudó mucho impulsando el proyecto y haciendo cosas que nadie más se animaba como cuando se animó a configurar VLANS o conseguir routers ganando hackatones. Eso me dío animos para seguir trabajando.
* **Armin Mesa:** Como miembro del r00thouse por apoyar en lo que estaba a su alcance animando a seguir con los proyectos, además por permitirme usar el server chipsito para este nodo.
* **Franklin Torres:** Dx por su ayuda y apoyo constante en cada problema que surgía cuando yo era parte del r00thouse.
* **Esteban Lima:** Tebo de buena gana ayudó a iniciar el proyecto LaOtraRed y a conseguirnos espacios de prueba, también colaboró en Cochabamba donde han estado construyendo LaOtraRed.

Voy a hacer un agradecimiento especial a [Philippe Rivière](https://visionscarto.net) o [**Fil**](mailto:fil@rezo.net), que es la persona que realizó una importante donación monetaria cuando empezabamos el proyecto con esa donación pudimos adquirir muchos enrutadores con los que empezamos a probar en serio la creación de una red distribuida y para montar un nodo de pruebas en el r00thouse.

Fil hizo un viaje largo desde Francia a Bolivia y antes de irse hizo el donativo sin anunciarlo a grandes voces ni esperar reconocimiento público, sólo confiando en que su donativo apoyaría al proyecto y así lo hizo ¡Gracias Fil!.

#### Algo más ####

Antes de pasar a la parte enteramente técnica me gustaría animar a muchos otros para que monten sus nodos o ayuden a montarlos y expandir una red distribuida y libre.

No es buena idea esperar a que otr@s nos brinden soluciones comunicacionales que probablemente no se ajustarán a nuestras necesidades reales, además se podrían aprovechar de nuestra situación por falta de comunicación para --imponernos tarfias excesivas y un mal servicio, como es el caso del internet en Bolivia.

Si tienes la posibilidad debes tomar acción apoyando la creación de redes libres para los y las ciudadanas, controladas y gestionadas por los mismos.

Rechaza medios de comunicación que te quitan la libertad de comunicarte libremente y anima a otros a hacerlo.

Puedes revisar [este post](/posts/resistirse-a-usar-ciertos-medios-comunicacion) que estoy escribiendo al respecto.

<span id="infraestructura"> </span>
### Infraestructura ###

Este diagrama explica a grandes rasgos la infraestructura del nodo:

![Nodo Chersky infraestructura](../static/imgs/posts/nodo_chersky.png)

Como se ve hay un  router no solamente reparte wifi para que se conencten varios clientes, también emite y recibe señales wifi (2.4 y 5Ghz) en modo adhoc para unirse a la red distribuida LaOtraRed.

El siguiente diagrama muestra con más detalle la infraestructura del nodo.

![Nodo Chersky infraestructura detalle](../static/imgs/posts/nodo_chersky_detalle.png)

Sólo hay un aparato enrutador que realiza la conexión a la red distribuida, este aparato también sirve como repartidor de red wifi para clientes, el conmutador de red es opcional y puede servir para conectar **equipos adicionales** como servidores que tendrán una IP pública (serán visibles por todos los nodos en LaOtraRed).

<span id="configuraciones"></span>
### Configuraciones de aparatos enrutadores ###

Esta es una configuración **experimental** de nodo para LaOtraRed y no compromete al proyecto.

En los diagramas de la infraestructura del nodo se puede ver que se conecta a la red distribuida de LaOtraRed por wifi (en 2.4 y 5Ghz) y además a una red local de clientes temporales.

* El nodo Chersky tiene reservado el bloque `10.64.3.64 /27` siguiendo una [política de asignación de IPs](https://wiki.lapaz.laotrared.net/redlibre/politica_de_asignacion_de_ips)

Lo último significa que este nodo puede usar como mejor le convenga un total 30 direcciones IP para equipos que van desde `10.64.3.65` a `10.64.3.94` y estas direcciones serán públicas en todo el espacio de la red distribuida.

#### TLWDR3600 ####

Este enrutador tiene instalado el S.O. [openwrt](https://openwrt.org) y sus funciones son:

* Conectarse a la red distribuida por wifi en 2.4 y 5 Ghz usando el bloque público `10.64.3.64 /27` asignado a este nodo.
* Crear una red local para **clientes** en `172.24.1.0 /24`.
* Servir de wifi hotspot en 2.4 Ghz.
* Servidor dhcp para asignar direcciones IP automáticamente a clientes.
* Servidor DNS para la red local.

##### /etc/config/network #####

Este es el archivo donde se definen las interfaces de red.

Por convenencia se ha definido VLANs para reservar puertos ethernet del aparato de forma ordenada siguiendo: [https://wiki.openwrt.org/toh/tp-link/tl-wdr3600#switch_ports_for_vlans](https://wiki.openwrt.org/toh/tp-link/tl-wdr3600#switch_ports_for_vlans), la definición de VLANs **no es estricamente necesaria**.

Por ejemplo he definido la VLAN 1 que abarca los puertos ethernet 1 y 2 y hace que cualquier dispositivo que se conecte allí pertenezca a una interfaz específica (en este caso "wiredmesh"), la VLAN 2 abarca los puertos ethernet 3 y 4 y cualquier dispositivo que se conecte a estos puertos pertenecerá a la interfaz "mesh", de igual manera con la VLAN 3 pero con la interfaz "wan" y usando el puerto ethernet wan.

Este el archivo de configuración completo:

    :::bash
    config interface 'loopback'
    	option ifname 'lo'
    	option proto 'static'
    	option ipaddr '127.0.0.1'
    	option netmask '255.0.0.0'
    
    config globals 'globals'
    	option ula_prefix 'fdec:6a81:414a::/48'
    # red mesh para conexiones cableadas (ethernet)
    config interface 'wiredmesh'
    	option ifname 'eth0.1'            # VLAN 1
    	option force_link '1'
    	option proto 'static'
    	option ipaddr '10.64.3.65'        # IPv4 publica del router
    	option netmask '255.255.255.224'  # mascara /27
    # red mesh para 2.4 Ghz
    config interface 'mesh_2G4'
    	option proto 'static'
    	option ipaddr '10.64.3.65'        # IPv4 publica del router
    	option netmask '255.255.255.224'
    	option ip6addr 'fc01:1934:fffe:9493:44d9:e7ff:fe4b:2661/128'
    # red mesh para 5 Ghz
    config interface 'mesh_5G'
    	option proto 'static'
    	option ipaddr '10.64.3.65'       # IP publica del router
    	option netmask '255.255.255.224'
    	option ip6addr 'fc01:1934:fffe:9493:44d9:e7ff:fe4b:2669/128'
    # red lan
    config interface 'lan'
    	option ifname 'eth0.2'           # VLAN 2
    	option force_link '1'
    	option type 'bridge'
    	option proto 'static'
    	option ipaddr '172.24.1.1'       # IPv4 del route en la red lan privada
    	option netmask '255.255.255.0'   # mascara /24
		
    config interface 'wan6'
    	option ifname 'eth0.3'
    	option proto 'dhcpv6'
    config interface 'wan'
    	option ifname 'usb0'
    	option proto 'dhcp'
    # Definicion de VLANs
    config switch
    	option name 'switch0'
    	option reset '1'
    	option enable_vlan '1'
    # VLAN 1
    config switch_vlan
    	option device 'switch0'
    	option vlan '1'
    	option ports '0t 2 3'
    # VLAN 2
    config switch_vlan
    	option device 'switch0'
    	option vlan '2'
    	option ports '0t 4 5'
    # VLAN 3
    config switch_vlan
    	option device 'switch0'
    	option vlan '3'
    	option ports '0t 1'

##### /etc/config/wireless #####

Como es un router DUAL BAND y este es un *nodo normal* dentro LaOtraRed se debe expandir la señal de la red distribuida en 2.4 y 5 Ghz, a continuación el archivo de configuración:

    :::bash
	# dispositivo 2.4Ghz
    config wifi-device 'radio0'
    	option type 'mac80211'
    	option hwmode '11g'
    	option path 'platform/ar934x_wmac'
    	option htmode 'HT20'
    	option country 'BO'                 # normativa Boliviana
		option channel '7'
    	option disabled '0'
    	option txpower '19'
    	option distance '1000'
    # dispositivo 5Ghz
    config wifi-device 'radio1'
    	option type 'mac80211'
    	option txpower '22'
    	option country 'BO'                 # normativa Boliviana
    	option channel '153'                # Canal comun para LaOtraRed
    	option hwmode '11a'
    	option path 'pci0000:00/0000:00:00.0'
    	option htmode 'HT20'
    	option disabled '0'
    # 5 Ghz mesh
    config wifi-iface
    	option device 'radio1'
    	option network 'mesh_5G'
    	option mode 'adhoc'
    	option ssid 'lapaz.laotrared.net'
    	option bssid 'BE:BA:CA:FE:BE:BE'    # MAC comun 5Ghz
    	option encryption 'none'
    # 2.4 Ghz mesh
    config wifi-iface
    	option device 'radio0'
    	option mode 'adhoc'
    	option network 'mesh_2G4'
    	option ssid 'lapaz.laotrared.net'
    	option bssid 'BE:BA:CA:FE:B3:B3'    # MAC comun 2.4Ghz
    	option encryption 'none'
    # 2.4 Ghz clientes (lan privada)
    config wifi-iface
    	option device 'radio0'
    	option mode 'ap'
    	option encryption 'none'            # tambien se puede usar cifrado aqui
    	option ssid 'Chersky - LaOtraRed'
    	option network 'lan'

##### /etc/config/firewall #####

Como se considera la interfaz **lan** una red privada, todos los dispositivos conectados que quieran ver contenido de equipos públicos dentro LaOtraRed La Paz (bloque 10.64.0.0 /15) lo harán a través de NAT. Esto quiere decir que se usará la IP `10.64.3.65` del router para enviar el tráfico desde la interfaz **lan** hacia otros nodos en LaOtraRed.

Por eso se agregan las siguientes opciones en el firewall:

    :::bash
    config zone
    	option input 'ACCEPT'
    	option output 'ACCEPT'
    	option name 'mesh'
    	option forward 'ACCEPT'
    	option network 'lan mesh_2G4 mesh_5G wiredmesh'
    	option masq '1'           # masquerading (NAT)
    
    config forwarding
    	option dest 'lan'
    	option src 'mesh'

##### /etc/config/babeld #####

Como se trata de una red distribuida, todos los nodos deben colaborar para brindar conexión a otros. Se utiliza el protocolo de enrutamiento dinámico [babel](https://www.irif.fr/~jch//software/babel/).

Hacemos que babeld haga algunas cosas:

* Diga a los nodos vecinos  que el bloque `10.64.3.64 /27` es su dominio y para llegar a ese bloque de direcciones IP lo harán a través de este router.
* Reciba rutas de otros vecinos dentro el bloque `10.64.0.0 /15` y se las avise a otros vecinos.
* Sea punto intermedio de conexión entre otros nodos cuando estos lo requieran.
* Detecta automáticamente otros nodos en la red y estos se unen automáticamente.

La ventaja de este protocolo es que elimina la posibilidad de que se formen bucles de enrutamiento y trabaja en IPv6 e IPv4.

Para utilizarlo se tiene instalado el paquete babeld con:

    opkg install babeld

Este es el archivo de configuración:

    :::bash
    package babeld
    config general
    	option 'random_id' 'true'
    	option 'ipv6_subtrees' 'true'
    
    ##### interfaces en las que babel participa
    # (wifi 2.4Ghz adhoc)
    config interface
    	option 'ifname' 'wlan0'
    	option channel '7'
    # (wifi 5Ghz adhoc)
    config interface
    	option 'ifname' 'wlan1'
    	option channel '153'
    
    ###### filtros para rutas de entrada (anunciadas por los vecinos)
    # permitir rutas del bloque 10.64.0.0 /15 (LaOtraRed La Paz - El Alto)
    config filter
    	option type 'in'
    	option ip '10.64.0.0/15'
    	option action 'allow'
    config filter
    	option type 'in'
    	option ip 'fc01:1934::/32'
    	option 'allow'
    
    ###### filtros para rutas internas (kernel)
    # anunciar a los vecinos el bloque de este nodo
    config filter
    	option type 'redistribute'
    	option ip '10.64.3.64/27'       # bloque IPv4 de este nodo
    	option action 'allow'
    config filter
    	option type 'redistribute'
    	option ip 'fc01:1934:fffe:9493:44d9:e7ff:fe4b:2662/128' # IPv6 (de pruebas)
    
    ###### denegar otras rutas no definidas
    config filter
    	option type 'in'
    	option action 'deny'
    config filter
    	option type 'redistribute'
    	option local 'true'
    	option action 'deny'

Con estas configuraciones el router funciona como nodo dentro la red distribuida. Si otro nodo tiene configuraciones similares estará haciendo crecer la red distribuida y libre.

Lo que en otro nodo se debería cambiar son las direcciones IP correspondientes a las que tengan asingadas,  ver [política de asignación de IPs](https://wiki.lapaz.laotrared.net/redlibre/politica_de_asignacion_de_ips) para más información.

##### dnsmasq #####

Se usa dnsmasq como servidor DNS y DHCP, no se han hecho cambios al arhivo `/etc/config/dhcp` pero si en el archivo de configuración global de dnsmasq para que dirija las búsquedas de dominios hacia una dirección IP, en  esta caso hacia el servidor chipsito.

Se ha puesto al principio del archivo `/etc/dnsmasq.conf` la línea:

    address=/#/10.64.3.66

Que retornará a 10.64.3.66 cualquier búsqueda no respondida desde /etc/hosts o DHCP y que no haya sido enviada a un servidor DNS upstream, esto funcionará para los dispositivos conectados en a la red **lan** y están como clientes de este nodo.

En 10.64.3.66 (server chipsito) hay una página de bienvenida que le indica al visitante los serivicios disponibles para este nodo.

##### /etc/hosts #####

En este archivo se han agregado entradas para dominios locales en este caso:

    127.0.0.1 localhost
    10.64.3.66 chersky.lor
    10.64.3.66 radio.chersky.lor
    10.64.3.66 wikipedia.chersky.lor
    10.64.3.66 botadero.chersky.lor
    10.64.3.66 fotos.chersky.lor
    10.64.3.66 anuncios.chersky.lor
    10.64.3.66 extra.chersky.lor

Se usa el Top Level Domain (TLD) `.lor` que viene de "LaOtraRed".

#### Observaciones ####

 * Todos los equipos conectados al wifi para clientes de este nodo, fácilmente podrán usar sus servicios ya que hay una página de bienvenida y dnsmasq los redirije a esta. Sin embargo, no hay una referencia a servidores DNS públicos en LaOtraRed. Mientras no se definan servidores DNS públicos se podría agregar entradas en /etc/hosts para los dominios de otros nodos.
 * Las conexiones en modo ad-hoc por wifi desde esto nodo hacia los demás nodos en la red distribuida, no se están cifrando desde este nodo, esto por que no se tiene aun definida una contraseña pública entre nodos.
 
----
Espero te haya servido, me despido con una foto de la Chersky.

<img src="../static/imgs/posts/chersky1.jpg" width="362">

base.html

<!DOCTYPE html>
<!--

Monomotapa - A Micro CMS
Copyright (C) 2014, Paul Munday.

PO Box 28228, Portland, OR, USA 97228
paul at paulmunday.net

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero  Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.

There should also be a copy of the AGPL in src/license.md that should be
accessible by going to <a href ="/license">/license<a> on this site.
-->
<html>
<head>
<title>{% if title -%}{{title}}{% endif %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="lang" content="es"/>
<meta name="author" content="Rodrigo Garcia Saenz"/>
<meta property="article:publisher" content="https://rmgss.net"/>
<meta property="og:site_name" content="Sitio personal de Rodrigo Garcia Saenz"/>
<!-- meta datos dinamicos -->
{%- for metadato in meta -%}
  <meta {{metadato.tag}}="{{metadato.name}}" content="{{metadato.content}}"/>
{%- endfor -%}
<!-- -->
<link rel="stylesheet" type="text/css" title="oscuro" href="/static/style_rmgss.css">
<link rel="alternate stylesheet" type="text/css" title="claro" href="/static/style_rmgss_claro.css">

<!-- {%- if css -%} -->
<!-- {%- for file in css %} -->
<!--     <link href="{{ url_for('static', filename=file) }}" rel="stylesheet" type="text/css"  /> -->
<!--     {%- endfor -%} -->
<!-- {%- endif %} -->
{% if internal_css %}
  <style type="text/css">
    {{internal_css}}
  </style>
{% endif %}
{%- if hlinks -%}
  {%- for item in hlinks -%}
    <link 
       {%- if item.href %} href="{{item.href}}"{% endif -%}
       {%- if item.rel %} rel="{{item.rel}}"{% endif -%} 
       {%- if item.type %} type="{{item.type}}"{% endif -%}
       {%- if item.media %} type="{{item.media}}"{% endif -%}
       {%- if item.hreflang %} type="{{item.hreflang}}"{% endif -%}
       {%- if item.charset %} type="{{item.charset}}"{% endif -%}
       >
     {% endfor %}
   {%- endif -%}



<link rel="apple-touch-icon" sizes="76x76" href="{{ url_for('static', filename='imgs/favicon/apple-touch-icon.png')}}">

<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='imgs/favicon/favicon-32x32.png')}}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='imgs/favicon/favicon-16x16.png')}}">
<link rel="manifest" href="{{ url_for('static', filename='imgs/favicon/site.webmanifest')}}">
<link rel="mask-icon" href="{{ url_for('static', filename='imgs/favicon/safari-pinned-tab.svg')}}" color="#5bbad5">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">

{# <link rel="shortcut icon" type="image/png" href="/{{ url_for('static', filename='imgs/favicon.png') }}> #}
</head>

<body onload="loadCssStyle()">

  <div id="wrap">
    <div>
      <p> 
	<a href="/">
	  <img src="/static/imgs/cabecera1.png">
	</a>
	<br>
	<span style="font-size:12px;">
	  <q><b>sitio personal</b> de Rodrigo Garcia Saenz.</q>
	</span>
      </p>
    </div>

    <div class="row">
      
      <!-- <div class="col fifth"> -->
      <!-- Importante dejar este -->
      <!-- </div> -->


      <div class="col fifth">

        <!-- Tabla de contenidos del post -->
        {%- if not exclude_toc -%}
        <div id="side_toc">
          <h3>Índice del post</h3>
          {{ toc|safe }}
        </div>
        {%- endif -%}

	<div id="nav_left">
	  <ul>
	    <dd>
	      <a href="/">
                <img class="leftbaricon"
                     src="/static/imgs/inicio.svg" alt="Inicio / Start" title="Inicio 🏡">
	      </a>
	    </dd>
	    <dd>
	      <a href="/posts">
                <img class="leftbaricon" src="/static/imgs/misc.svg" alt="Posts" title="Posts">
                Posts
	      </a>		
	      <ul>
		<dd>
		  <a href="/posts/categorias">
		    <img
                      class="leftbaricon"
                      src="/static/imgs/categorias.svg" alt="Categorías" title="Categorías">
		    Categorías
		  </a>
		</dd>
	      </ul>
	    </dd>
	    <dd>	
	      <a href="/posts/categoria/fotos">
		<img
                  class="leftbaricon"
                  src="/static/imgs/fotos.svg" alt="fotos" title="fotos 🖼">
		Fotos
	      </a>
	      <dd>
		<a href="/acerca_de_mi">
		  <img src="/static/imgs/acercade.svg" alt="acerca de mi" title="Acerca de mi">
		  Acerca de mi
		</a>
	      </dd>
	      <dd>	
	        <a href="/contacto">
		  <img
                    class="leftbaricon"
                    src="/static/imgs/contacto.svg" alt="contacto" title="Contacto 📨">
		  Contacto
	        </a>
	        
	      </dd>
              
	  </ul>
	  <ul>
	    <hr>
	    {%- for item in navigation.navigation.values() -%}
	      <li><a href="
			   {%- if item.url -%}{{item.url}}
			   {%- elif item.urlfor -%}
			   {%- if item.urlfor == "source" -%}
		           {{ url_for(item.urlfor, page=navigation.page) }}
		           {%- else -%}
		           {{ url_for(item.urlfor) }}
		           {%- endif -%}
		           {%- else -%}
		           {{ url_for('staticpage', page=item.base) }}
		           {%- endif -%}
		           
		           {%- if item.rel -%}
		           " rel="{{item.rel}} 
			   {%- endif -%}
			   ">{{item.link_text}}</a></li>
	    {% endfor -%}
	  </ul>
	  <p>
	    <a href="/rss" >
	      <img src="/static/imgs/rss.png" width="24" heigth="24">
	      RSS
	    </a>
	  </p>
	</div>

        <!-- consejo del dia -->
	<div id="consejo_del_dia">
	  <b>Consejo del día</b>
	  <hr>
	  {% if contexto %}
	    {{ contexto['consejo']|safe }}
	  {% endif %}
	</div>

	<!-- Noticias Realcionadas si es un post -->
        <div id="noticias_relacionadas">
	  {% if contexto is defined %}
	    {% if contexto['relacionadas'] is defined %}
	      <h3>Posts/Noticias relacionadas</h3>
	      {% for post_relacionado in contexto['relacionadas'] %}
	        <p>
	          <a href="/posts/{{ post_relacionado }}">
	            {{ post_relacionado|n_heading }}
	          </a>
	        </p>
	        <hr>
	      {% endfor %}
	    {% endif %}
	  {% endif %}
        </div>

      </div>


      
      <!-- Contenido -->
      <div class="col fill">
	
	<!-- Cabecera -->
	<div style="margin-bottom: 7px">
	  <form>
	    <input type="submit" onclick="cambiarEstilo('oscuro'); return false;" name="theme" value="☻" id="oscuro" style="background-color: #333933; color: #C3C4C2; border-radius:3px;">
	    
	    <input type="submit" onclick="cambiarEstilo('claro'); return false;" name="theme" value="☺" id="claro" style="background-color: #D1E9D3; color: #131914; border-radius:3px;">
	  </form> 
	</div>

	<!-- Contenido -->
	{% block content %}{% endblock %}
        
        <!-- nav bottom (para pantallas chicas) -->
        <div id="nav_bottom">
	  <ul>
	    <dd>
	      <a href="/">
                <img src="/static/imgs/inicio.svg"
                     class="leftbaricon"
                     title="Inicio 🏡"
                     alt="Inicio">
	      </a>
	    </dd>
	    <dd>
	      <a href="/posts">
                <img
                  class="leftbaricon"
                  title="Posts"
                  src="/static/imgs/misc.svg" alt="posts">
                Posts
	      </a>		
	      <ul>
	        <dd>
		  <a href="/posts/categorias">
		    <img
                      class="leftbaricon"
                      title="Categorías"
                      src="/static/imgs/categorias.svg" alt="categorías">
                    categorías
		  </a>
	        </dd>
	      </ul>
	    </dd>
	    <dd>
	      <a href="/posts/categoria/fotos">
		<img
                  class="leftbaricon"
                  title="Fotos 🖼"
                  src="/static/imgs/fotos.svg" alt="fotos">
                fotos
	      </a>

	    </dd>

	    <dd>
	      <a href="/acerca_de_mi">
		<img
                  class="leftbaricon"
                  title="Acerca de mi"
                  src="/static/imgs/acercade.svg" alt="Acerca de mi">
                acerca de
	      </a>
	    </dd>
	  </ul>

	  <dd>	
	    <a href="/contacto">
	      <img
                class="leftbaricon"
                title="Contacto 📨"
                src="/static/imgs/contacto.svg" alt="Contacto">
              contacto
	    </a>
	  </dd>
	  </ul>
	  <ul>
	    <hr>
	    {%- for item in navigation.navigation.values() -%}
	    <li><a href="
		         {%- if item.url -%}{{item.url}}
		         {%- elif item.urlfor -%}
		         {%- if item.urlfor == "source" -%}
		   {{ url_for(item.urlfor, page=navigation.page) }}
		   {%- else -%}
		   {{ url_for(item.urlfor) }}
		   {%- endif -%}
		   {%- else -%}
		   {{ url_for('staticpage', page=item.base) }}
		   {%- endif -%}
		   
		   {%- if item.rel -%}
		   " rel="{{item.rel}} 
		         {%- endif -%}
		         ">{{item.link_text}}</a></li>
	    {% endfor -%}
	  </ul>
	  <p>
	    <a href="/rss" >
	      <img src="/static/imgs/rss.png" width="24" heigth="24">
	      RSS
	    </a>
	  </p>
        </div>

	<!-- Noticias Realcionadas si es un post -->
        <div id="noticias_relacionadas_bottom">
	  {% if contexto is defined %}
	    {% if contexto['relacionadas'] is defined %}
	      <h3>Posts/Noticias relacionadas</h3>
	      {% for post_relacionado in contexto['relacionadas'] %}
	        <p>
	          <a href="/posts/{{ post_relacionado }}">
	            {{ post_relacionado|n_heading }}
	          </a>
	        </p>
	        <hr>
	      {% endfor %}
	    {% endif %}
	  {% endif %}
        </div>
        <!-- consejo del dia -->
	<div id="consejo_del_dia_bottom">
	  <b>Consejo del día</b>
	  <hr>
	  {% if contexto %}
	    {{ contexto['consejo']|safe }}
	  {% endif %}
	</div>

      </div>

    </div> <!-- row -->




  </div>

  <div id="footer">
    <p id="footer">
      Este sitio web es software libre aquí el <a href="https://notabug.org/strysg/monimatapa">código fuente</a>.<br>
      El contenido de este sitio esta bajo una licencia Creative Commons <a href="https://creativecommons.org/licenses/by/4.0/">Attribution 4.0 International (CC BY 4.0)</a> a menos que se indique lo contrario.
      <!-- footer goes here -->
      {% if footer %}
      {{footer}}
      {% endif %}

    </p>
  </div>

  <!-- javascript -->
  <script language="javascript">
   function loadCssStyle() {
     const _varStyleName = 'style_css';
     var lsStyle = window.localStorage.getItem(_varStyleName);
     if (!lsStyle) {
       // default
       window.localStorage.setItem('style_css', 'oscuro');
       lsStyle = window.localStorage.getItem(_varStyleName);
     }
     var i, link_tag = document.getElementsByTagName("link");
     // inspeccionando hojas de estilo css cargadas
     for (
       i = 0, 
       link_tag = document.getElementsByTagName("link") ; i < link_tag.length ;
       i++ ) {
       if ((link_tag[i].rel.indexOf( "stylesheet" ) != -1)) {
         link_tag[i].disabled = true;
         if (link_tag[i].title === lsStyle) {
	   link_tag[i].disabled = false ;
           console.log('enabling', link_tag[i].title);
         }
       }
     }
   }
   
   function cambiarEstilo(value) {
     window.localStorage.setItem('style_css', value);
     loadCssStyle();
   }
  </script>
  <!-- --->

</body>
</html>

post.html

{% extends "base.html" %}
{% block content %}
  <article class="h-entry">
    <div> 
      {% if heading %}<h1 class="p-name">{{heading}}</h1>{% endif %}
    </div> 
    {# <hr> #}
    {# {{ meta }} #}
    {# <hr> #}
    {% if attributes %}
      <div id="attributes">
	<p id="post-details">
	  {% if attributes['date'] %}
            <time class="dt-published" datetime="{{attributes['date']}}">
	      {% if attributes['date-text'] %} 
		{{attributes['date-text']}}
	      {% else %}
		{{attributes['date']}}
	      {% endif %}
            </time>
          {% endif %}
	  {% if attributes['author'] %}
	    <span class="p-author">{{attributes['author']}}</span>
	  {% endif %}
	  <a class="u-url" href="/{{name}}">permalink.</a>
	</p>
	{% if attributes['summary'] %}
	  <p class="p-summary">{{attributes['summary']}}</p>
	{% endif %}
      </div>
    {% endif %}
    <div class='e-content'> 

      <div align="right">
	{% if contexto['ultima_modificacion'] %}
	  <small>Actualizado - {{ contexto['ultima_modificacion'] }}</small>
	{% endif %}
      </div>

      {% if categorias %} 
      	<div class="categorias"> 
      	{% for cat in categorias %}
      	  <a href="/posts/categoria/{{ cat }}">#{{ cat }}</a>
      	{% endfor %} 
      	</div> 
      {% endif %} 
      
      {{contents}}
    </div>
  </article>
{% endblock %}
Consejo del día

Si tienes la posiblidad, haz donaciones a organizaciones sin ánimo de lucro para apoyar su trabajo.

Muchas de estas organizaciones se mantienen gracias a la gente que les apoya con donaciones monetarias.