aboutsummaryrefslogtreecommitdiff
path: root/cgi
diff options
context:
space:
mode:
Diffstat (limited to 'cgi')
-rw-r--r--cgi/.htaccess9
-rw-r--r--cgi/BeautifulSoup.py2017
-rw-r--r--cgi/GeoIP.datbin0 -> 878459 bytes
-rw-r--r--cgi/anarkia.py439
-rw-r--r--cgi/api.py392
-rw-r--r--cgi/database.py69
-rw-r--r--cgi/fcgi.py1332
-rw-r--r--cgi/formatting.py425
-rw-r--r--cgi/framework.py467
-rw-r--r--cgi/geoip.py128
-rw-r--r--cgi/img.py416
-rw-r--r--cgi/locale/es/LC_MESSAGES/weabot.mobin0 -> 17305 bytes
-rw-r--r--cgi/manage.py1823
-rw-r--r--cgi/markdown.py2044
-rw-r--r--cgi/oekaki.py176
-rw-r--r--cgi/post.py1260
-rw-r--r--cgi/proxy.txt3251
-rw-r--r--cgi/quotes.conf13
-rw-r--r--cgi/template.py117
-rw-r--r--cgi/templates/anarkia.html329
-rw-r--r--cgi/templates/banned.html34
-rw-r--r--cgi/templates/base_bottom.html3
-rw-r--r--cgi/templates/base_top.html55
-rw-r--r--cgi/templates/board.0.html230
-rw-r--r--cgi/templates/board.html264
-rw-r--r--cgi/templates/board.jp.html271
-rw-r--r--cgi/templates/catalog.html30
-rw-r--r--cgi/templates/error.html7
-rw-r--r--cgi/templates/exception.html36
-rw-r--r--cgi/templates/home.rss24
-rw-r--r--cgi/templates/htaccess24
-rw-r--r--cgi/templates/kako.html60
-rw-r--r--cgi/templates/manage/addboard.html21
-rw-r--r--cgi/templates/manage/bans.html92
-rw-r--r--cgi/templates/manage/boardoptions.html195
-rw-r--r--cgi/templates/manage/changepassword.html24
-rw-r--r--cgi/templates/manage/delete.html23
-rw-r--r--cgi/templates/manage/filters.html119
-rw-r--r--cgi/templates/manage/ipdelete.html24
-rw-r--r--cgi/templates/manage/ipshow.html73
-rw-r--r--cgi/templates/manage/lockboard.html20
-rw-r--r--cgi/templates/manage/login.html21
-rw-r--r--cgi/templates/manage/logs.html17
-rw-r--r--cgi/templates/manage/manage.html22
-rw-r--r--cgi/templates/manage/menu.html30
-rw-r--r--cgi/templates/manage/message.html8
-rw-r--r--cgi/templates/manage/mod.html96
-rw-r--r--cgi/templates/manage/move.html60
-rw-r--r--cgi/templates/manage/quotes.html12
-rw-r--r--cgi/templates/manage/rebuild.html20
-rw-r--r--cgi/templates/manage/recent_images.html24
-rw-r--r--cgi/templates/manage/recyclebin.html72
-rw-r--r--cgi/templates/manage/reports.html58
-rw-r--r--cgi/templates/manage/search.html27
-rw-r--r--cgi/templates/manage/staff.html63
-rw-r--r--cgi/templates/mobile/base_top.html14
-rw-r--r--cgi/templates/mobile/board.html55
-rw-r--r--cgi/templates/mobile/error.html6
-rw-r--r--cgi/templates/mobile/latest.html14
-rw-r--r--cgi/templates/mobile/newest.html14
-rw-r--r--cgi/templates/mobile/threadlist.html43
-rw-r--r--cgi/templates/mobile/txt_newthread.html35
-rw-r--r--cgi/templates/mobile/txt_thread.html74
-rw-r--r--cgi/templates/mobile/txt_threadlist.html26
-rw-r--r--cgi/templates/mod.html86
-rw-r--r--cgi/templates/navbar.html16
-rw-r--r--cgi/templates/paint.html79
-rw-r--r--cgi/templates/redirect.html12
-rw-r--r--cgi/templates/report.html29
-rw-r--r--cgi/templates/revision.html1
-rw-r--r--cgi/templates/stats.html163
-rw-r--r--cgi/templates/txt_archive.html104
-rw-r--r--cgi/templates/txt_base_top.html44
-rw-r--r--cgi/templates/txt_board.en.html137
-rw-r--r--cgi/templates/txt_board.html137
-rw-r--r--cgi/templates/txt_error.html50
-rw-r--r--cgi/templates/txt_thread.en.html105
-rw-r--r--cgi/templates/txt_thread.html101
-rw-r--r--cgi/templates/txt_threadlist.html67
-rw-r--r--cgi/tenjin.py2118
-rw-r--r--cgi/tor.txt1140
-rwxr-xr-xcgi/weabot.py1021
82 files changed, 22557 insertions, 0 deletions
diff --git a/cgi/.htaccess b/cgi/.htaccess
new file mode 100644
index 0000000..97a4f17
--- /dev/null
+++ b/cgi/.htaccess
@@ -0,0 +1,9 @@
+AddHandler cgi-script .py
+Options +ExecCGI
+
+# Uncomment if you want pretty URL (ie cgi/post)
+#RewriteEngine On
+#RewriteBase /cgi/
+#RewriteRule ^weabot\.py/ - [L]
+#RewriteRule ^(.*)$ weabot.py/$1 [L]
+
diff --git a/cgi/BeautifulSoup.py b/cgi/BeautifulSoup.py
new file mode 100644
index 0000000..7278215
--- /dev/null
+++ b/cgi/BeautifulSoup.py
@@ -0,0 +1,2017 @@
+"""Beautiful Soup
+Elixir and Tonic
+"The Screen-Scraper's Friend"
+http://www.crummy.com/software/BeautifulSoup/
+
+Beautiful Soup parses a (possibly invalid) XML or HTML document into a
+tree representation. It provides methods and Pythonic idioms that make
+it easy to navigate, search, and modify the tree.
+
+A well-formed XML/HTML document yields a well-formed data
+structure. An ill-formed XML/HTML document yields a correspondingly
+ill-formed data structure. If your document is only locally
+well-formed, you can use this library to find and process the
+well-formed part of it.
+
+Beautiful Soup works with Python 2.2 and up. It has no external
+dependencies, but you'll have more success at converting data to UTF-8
+if you also install these three packages:
+
+* chardet, for auto-detecting character encodings
+ http://chardet.feedparser.org/
+* cjkcodecs and iconv_codec, which add more encodings to the ones supported
+ by stock Python.
+ http://cjkpython.i18n.org/
+
+Beautiful Soup defines classes for two main parsing strategies:
+
+ * BeautifulStoneSoup, for parsing XML, SGML, or your domain-specific
+ language that kind of looks like XML.
+
+ * BeautifulSoup, for parsing run-of-the-mill HTML code, be it valid
+ or invalid. This class has web browser-like heuristics for
+ obtaining a sensible parse tree in the face of common HTML errors.
+
+Beautiful Soup also defines a class (UnicodeDammit) for autodetecting
+the encoding of an HTML or XML document, and converting it to
+Unicode. Much of this code is taken from Mark Pilgrim's Universal Feed Parser.
+
+For more than you ever wanted to know about Beautiful Soup, see the
+documentation:
+http://www.crummy.com/software/BeautifulSoup/documentation.html
+
+Here, have some legalese:
+
+Copyright (c) 2004-2010, Leonard Richardson
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following
+ disclaimer in the documentation and/or other materials provided
+ with the distribution.
+
+ * Neither the name of the the Beautiful Soup Consortium and All
+ Night Kosher Bakery nor the names of its contributors may be
+ used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE, DAMMIT.
+
+"""
+from __future__ import generators
+
+__author__ = "Leonard Richardson (leonardr@segfault.org)"
+__version__ = "3.2.1"
+__copyright__ = "Copyright (c) 2004-2012 Leonard Richardson"
+__license__ = "New-style BSD"
+
+from sgmllib import SGMLParser, SGMLParseError
+import codecs
+import markupbase
+import types
+import re
+import sgmllib
+try:
+ from htmlentitydefs import name2codepoint
+except ImportError:
+ name2codepoint = {}
+try:
+ set
+except NameError:
+ from sets import Set as set
+
+#These hacks make Beautiful Soup able to parse XML with namespaces
+sgmllib.tagfind = re.compile('[a-zA-Z][-_.:a-zA-Z0-9]*')
+markupbase._declname_match = re.compile(r'[a-zA-Z][-_.:a-zA-Z0-9]*\s*').match
+
+DEFAULT_OUTPUT_ENCODING = "utf-8"
+
+def _match_css_class(str):
+ """Build a RE to match the given CSS class."""
+ return re.compile(r"(^|.*\s)%s($|\s)" % str)
+
+# First, the classes that represent markup elements.
+
+class PageElement(object):
+ """Contains the navigational information for some part of the page
+ (either a tag or a piece of text)"""
+
+ def _invert(h):
+ "Cheap function to invert a hash."
+ i = {}
+ for k,v in h.items():
+ i[v] = k
+ return i
+
+ XML_ENTITIES_TO_SPECIAL_CHARS = { "apos" : "'",
+ "quot" : '"',
+ "amp" : "&",
+ "lt" : "<",
+ "gt" : ">" }
+
+ XML_SPECIAL_CHARS_TO_ENTITIES = _invert(XML_ENTITIES_TO_SPECIAL_CHARS)
+
+ def setup(self, parent=None, previous=None):
+ """Sets up the initial relations between this element and
+ other elements."""
+ self.parent = parent
+ self.previous = previous
+ self.next = None
+ self.previousSibling = None
+ self.nextSibling = None
+ if self.parent and self.parent.contents:
+ self.previousSibling = self.parent.contents[-1]
+ self.previousSibling.nextSibling = self
+
+ def replaceWith(self, replaceWith):
+ oldParent = self.parent
+ myIndex = self.parent.index(self)
+ if hasattr(replaceWith, "parent")\
+ and replaceWith.parent is self.parent:
+ # We're replacing this element with one of its siblings.
+ index = replaceWith.parent.index(replaceWith)
+ if index and index < myIndex:
+ # Furthermore, it comes before this element. That
+ # means that when we extract it, the index of this
+ # element will change.
+ myIndex = myIndex - 1
+ self.extract()
+ oldParent.insert(myIndex, replaceWith)
+
+ def replaceWithChildren(self):
+ myParent = self.parent
+ myIndex = self.parent.index(self)
+ self.extract()
+ reversedChildren = list(self.contents)
+ reversedChildren.reverse()
+ for child in reversedChildren:
+ myParent.insert(myIndex, child)
+
+ def extract(self):
+ """Destructively rips this element out of the tree."""
+ if self.parent:
+ try:
+ del self.parent.contents[self.parent.index(self)]
+ except ValueError:
+ pass
+
+ #Find the two elements that would be next to each other if
+ #this element (and any children) hadn't been parsed. Connect
+ #the two.
+ lastChild = self._lastRecursiveChild()
+ nextElement = lastChild.next
+
+ if self.previous:
+ self.previous.next = nextElement
+ if nextElement:
+ nextElement.previous = self.previous
+ self.previous = None
+ lastChild.next = None
+
+ self.parent = None
+ if self.previousSibling:
+ self.previousSibling.nextSibling = self.nextSibling
+ if self.nextSibling:
+ self.nextSibling.previousSibling = self.previousSibling
+ self.previousSibling = self.nextSibling = None
+ return self
+
+ def _lastRecursiveChild(self):
+ "Finds the last element beneath this object to be parsed."
+ lastChild = self
+ while hasattr(lastChild, 'contents') and lastChild.contents:
+ lastChild = lastChild.contents[-1]
+ return lastChild
+
+ def insert(self, position, newChild):
+ if isinstance(newChild, basestring) \
+ and not isinstance(newChild, NavigableString):
+ newChild = NavigableString(newChild)
+
+ position = min(position, len(self.contents))
+ if hasattr(newChild, 'parent') and newChild.parent is not None:
+ # We're 'inserting' an element that's already one
+ # of this object's children.
+ if newChild.parent is self:
+ index = self.index(newChild)
+ if index > position:
+ # Furthermore we're moving it further down the
+ # list of this object's children. That means that
+ # when we extract this element, our target index
+ # will jump down one.
+ position = position - 1
+ newChild.extract()
+
+ newChild.parent = self
+ previousChild = None
+ if position == 0:
+ newChild.previousSibling = None
+ newChild.previous = self
+ else:
+ previousChild = self.contents[position-1]
+ newChild.previousSibling = previousChild
+ newChild.previousSibling.nextSibling = newChild
+ newChild.previous = previousChild._lastRecursiveChild()
+ if newChild.previous:
+ newChild.previous.next = newChild
+
+ newChildsLastElement = newChild._lastRecursiveChild()
+
+ if position >= len(self.contents):
+ newChild.nextSibling = None
+
+ parent = self
+ parentsNextSibling = None
+ while not parentsNextSibling:
+ parentsNextSibling = parent.nextSibling
+ parent = parent.parent
+ if not parent: # This is the last element in the document.
+ break
+ if parentsNextSibling:
+ newChildsLastElement.next = parentsNextSibling
+ else:
+ newChildsLastElement.next = None
+ else:
+ nextChild = self.contents[position]
+ newChild.nextSibling = nextChild
+ if newChild.nextSibling:
+ newChild.nextSibling.previousSibling = newChild
+ newChildsLastElement.next = nextChild
+
+ if newChildsLastElement.next:
+ newChildsLastElement.next.previous = newChildsLastElement
+ self.contents.insert(position, newChild)
+
+ def append(self, tag):
+ """Appends the given tag to the contents of this tag."""
+ self.insert(len(self.contents), tag)
+
+ def findNext(self, name=None, attrs={}, text=None, **kwargs):
+ """Returns the first item that matches the given criteria and
+ appears after this Tag in the document."""
+ return self._findOne(self.findAllNext, name, attrs, text, **kwargs)
+
+ def findAllNext(self, name=None, attrs={}, text=None, limit=None,
+ **kwargs):
+ """Returns all items that match the given criteria and appear
+ after this Tag in the document."""
+ return self._findAll(name, attrs, text, limit, self.nextGenerator,
+ **kwargs)
+
+ def findNextSibling(self, name=None, attrs={}, text=None, **kwargs):
+ """Returns the closest sibling to this Tag that matches the
+ given criteria and appears after this Tag in the document."""
+ return self._findOne(self.findNextSiblings, name, attrs, text,
+ **kwargs)
+
+ def findNextSiblings(self, name=None, attrs={}, text=None, limit=None,
+ **kwargs):
+ """Returns the siblings of this Tag that match the given
+ criteria and appear after this Tag in the document."""
+ return self._findAll(name, attrs, text, limit,
+ self.nextSiblingGenerator, **kwargs)
+ fetchNextSiblings = findNextSiblings # Compatibility with pre-3.x
+
+ def findPrevious(self, name=None, attrs={}, text=None, **kwargs):
+ """Returns the first item that matches the given criteria and
+ appears before this Tag in the document."""
+ return self._findOne(self.findAllPrevious, name, attrs, text, **kwargs)
+
+ def findAllPrevious(self, name=None, attrs={}, text=None, limit=None,
+ **kwargs):
+ """Returns all items that match the given criteria and appear
+ before this Tag in the document."""
+ return self._findAll(name, attrs, text, limit, self.previousGenerator,
+ **kwargs)
+ fetchPrevious = findAllPrevious # Compatibility with pre-3.x
+
+ def findPreviousSibling(self, name=None, attrs={}, text=None, **kwargs):
+ """Returns the closest sibling to this Tag that matches the
+ given criteria and appears before this Tag in the document."""
+ return self._findOne(self.findPreviousSiblings, name, attrs, text,
+ **kwargs)
+
+ def findPreviousSiblings(self, name=None, attrs={}, text=None,
+ limit=None, **kwargs):
+ """Returns the siblings of this Tag that match the given
+ criteria and appear before this Tag in the document."""
+ return self._findAll(name, attrs, text, limit,
+ self.previousSiblingGenerator, **kwargs)
+ fetchPreviousSiblings = findPreviousSiblings # Compatibility with pre-3.x
+
+ def findParent(self, name=None, attrs={}, **kwargs):
+ """Returns the closest parent of this Tag that matches the given
+ criteria."""
+ # NOTE: We can't use _findOne because findParents takes a different
+ # set of arguments.
+ r = None
+ l = self.findParents(name, attrs, 1)
+ if l:
+ r = l[0]
+ return r
+
+ def findParents(self, name=None, attrs={}, limit=None, **kwargs):
+ """Returns the parents of this Tag that match the given
+ criteria."""
+
+ return self._findAll(name, attrs, None, limit, self.parentGenerator,
+ **kwargs)
+ fetchParents = findParents # Compatibility with pre-3.x
+
+ #These methods do the real heavy lifting.
+
+ def _findOne(self, method, name, attrs, text, **kwargs):
+ r = None
+ l = method(name, attrs, text, 1, **kwargs)
+ if l:
+ r = l[0]
+ return r
+
+ def _findAll(self, name, attrs, text, limit, generator, **kwargs):
+ "Iterates over a generator looking for things that match."
+
+ if isinstance(name, SoupStrainer):
+ strainer = name
+ # (Possibly) special case some findAll*(...) searches
+ elif text is None and not limit and not attrs and not kwargs:
+ # findAll*(True)
+ if name is True:
+ return [element for element in generator()
+ if isinstance(element, Tag)]
+ # findAll*('tag-name')
+ elif isinstance(name, basestring):
+ return [element for element in generator()
+ if isinstance(element, Tag) and
+ element.name == name]
+ else:
+ strainer = SoupStrainer(name, attrs, text, **kwargs)
+ # Build a SoupStrainer
+ else:
+ strainer = SoupStrainer(name, attrs, text, **kwargs)
+ results = ResultSet(strainer)
+ g = generator()
+ while True:
+ try:
+ i = g.next()
+ except StopIteration:
+ break
+ if i:
+ found = strainer.search(i)
+ if found:
+ results.append(found)
+ if limit and len(results) >= limit:
+ break
+ return results
+
+ #These Generators can be used to navigate starting from both
+ #NavigableStrings and Tags.
+ def nextGenerator(self):
+ i = self
+ while i is not None:
+ i = i.next
+ yield i
+
+ def nextSiblingGenerator(self):
+ i = self
+ while i is not None:
+ i = i.nextSibling
+ yield i
+
+ def previousGenerator(self):
+ i = self
+ while i is not None:
+ i = i.previous
+ yield i
+
+ def previousSiblingGenerator(self):
+ i = self
+ while i is not None:
+ i = i.previousSibling
+ yield i
+
+ def parentGenerator(self):
+ i = self
+ while i is not None:
+ i = i.parent
+ yield i
+
+ # Utility methods
+ def substituteEncoding(self, str, encoding=None):
+ encoding = encoding or "utf-8"
+ return str.replace("%SOUP-ENCODING%", encoding)
+
+ def toEncoding(self, s, encoding=None):
+ """Encodes an object to a string in some encoding, or to Unicode.
+ ."""
+ if isinstance(s, unicode):
+ if encoding:
+ s = s.encode(encoding)
+ elif isinstance(s, str):
+ if encoding:
+ s = s.encode(encoding)
+ else:
+ s = unicode(s)
+ else:
+ if encoding:
+ s = self.toEncoding(str(s), encoding)
+ else:
+ s = unicode(s)
+ return s
+
+ BARE_AMPERSAND_OR_BRACKET = re.compile("([<>]|"
+ + "&(?!#\d+;|#x[0-9a-fA-F]+;|\w+;)"
+ + ")")
+
+ def _sub_entity(self, x):
+ """Used with a regular expression to substitute the
+ appropriate XML entity for an XML special character."""
+ return "&" + self.XML_SPECIAL_CHARS_TO_ENTITIES[x.group(0)[0]] + ";"
+
+
+class NavigableString(unicode, PageElement):
+
+ def __new__(cls, value):
+ """Create a new NavigableString.
+
+ When unpickling a NavigableString, this method is called with
+ the string in DEFAULT_OUTPUT_ENCODING. That encoding needs to be
+ passed in to the superclass's __new__ or the superclass won't know
+ how to handle non-ASCII characters.
+ """
+ if isinstance(value, unicode):
+ return unicode.__new__(cls, value)
+ return unicode.__new__(cls, value, DEFAULT_OUTPUT_ENCODING)
+
+ def __getnewargs__(self):
+ return (NavigableString.__str__(self),)
+
+ def __getattr__(self, attr):
+ """text.string gives you text. This is for backwards
+ compatibility for Navigable*String, but for CData* it lets you
+ get the string without the CData wrapper."""
+ if attr == 'string':
+ return self
+ else:
+ raise AttributeError, "'%s' object has no attribute '%s'" % (self.__class__.__name__, attr)
+
+ def __unicode__(self):
+ return str(self).decode(DEFAULT_OUTPUT_ENCODING)
+
+ def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING):
+ # Substitute outgoing XML entities.
+ data = self.BARE_AMPERSAND_OR_BRACKET.sub(self._sub_entity, self)
+ if encoding:
+ return data.encode(encoding)
+ else:
+ return data
+
+class CData(NavigableString):
+
+ def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING):
+ return "<![CDATA[%s]]>" % NavigableString.__str__(self, encoding)
+
+class ProcessingInstruction(NavigableString):
+ def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING):
+ output = self
+ if "%SOUP-ENCODING%" in output:
+ output = self.substituteEncoding(output, encoding)
+ return "<?%s?>" % self.toEncoding(output, encoding)
+
+class Comment(NavigableString):
+ def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING):
+ return "<!--%s-->" % NavigableString.__str__(self, encoding)
+
+class Declaration(NavigableString):
+ def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING):
+ return "<!%s>" % NavigableString.__str__(self, encoding)
+
+class Tag(PageElement):
+
+ """Represents a found HTML tag with its attributes and contents."""
+
+ def _convertEntities(self, match):
+ """Used in a call to re.sub to replace HTML, XML, and numeric
+ entities with the appropriate Unicode characters. If HTML
+ entities are being converted, any unrecognized entities are
+ escaped."""
+ x = match.group(1)
+ if self.convertHTMLEntities and x in name2codepoint:
+ return unichr(name2codepoint[x])
+ elif x in self.XML_ENTITIES_TO_SPECIAL_CHARS:
+ if self.convertXMLEntities:
+ return self.XML_ENTITIES_TO_SPECIAL_CHARS[x]
+ else:
+ return u'&%s;' % x
+ elif len(x) > 0 and x[0] == '#':
+ # Handle numeric entities
+ if len(x) > 1 and x[1] == 'x':
+ return unichr(int(x[2:], 16))
+ else:
+ return unichr(int(x[1:]))
+
+ elif self.escapeUnrecognizedEntities:
+ return u'&amp;%s;' % x
+ else:
+ return u'&%s;' % x
+
+ def __init__(self, parser, name, attrs=None, parent=None,
+ previous=None):
+ "Basic constructor."
+
+ # We don't actually store the parser object: that lets extracted
+ # chunks be garbage-collected
+ self.parserClass = parser.__class__
+ self.isSelfClosing = parser.isSelfClosingTag(name)
+ self.name = name
+ if attrs is None:
+ attrs = []
+ elif isinstance(attrs, dict):
+ attrs = attrs.items()
+ self.attrs = attrs
+ self.contents = []
+ self.setup(parent, previous)
+ self.hidden = False
+ self.containsSubstitutions = False
+ self.convertHTMLEntities = parser.convertHTMLEntities
+ self.convertXMLEntities = parser.convertXMLEntities
+ self.escapeUnrecognizedEntities = parser.escapeUnrecognizedEntities
+
+ # Convert any HTML, XML, or numeric entities in the attribute values.
+ convert = lambda(k, val): (k,
+ re.sub("&(#\d+|#x[0-9a-fA-F]+|\w+);",
+ self._convertEntities,
+ val))
+ self.attrs = map(convert, self.attrs)
+
+ def getString(self):
+ if (len(self.contents) == 1
+ and isinstance(self.contents[0], NavigableString)):
+ return self.contents[0]
+
+ def setString(self, string):
+ """Replace the contents of the tag with a string"""
+ self.clear()
+ self.append(string)
+
+ string = property(getString, setString)
+
+ def getText(self, separator=u""):
+ if not len(self.contents):
+ return u""
+ stopNode = self._lastRecursiveChild().next
+ strings = []
+ current = self.contents[0]
+ while current is not stopNode:
+ if isinstance(current, NavigableString):
+ strings.append(current.strip())
+ current = current.next
+ return separator.join(strings)
+
+ text = property(getText)
+
+ def get(self, key, default=None):
+ """Returns the value of the 'key' attribute for the tag, or
+ the value given for 'default' if it doesn't have that
+ attribute."""
+ return self._getAttrMap().get(key, default)
+
+ def clear(self):
+ """Extract all children."""
+ for child in self.contents[:]:
+ child.extract()
+
+ def index(self, element):
+ for i, child in enumerate(self.contents):
+ if child is element:
+ return i
+ raise ValueError("Tag.index: element not in tag")
+
+ def has_key(self, key):
+ return self._getAttrMap().has_key(key)
+
+ def __getitem__(self, key):
+ """tag[key] returns the value of the 'key' attribute for the tag,
+ and throws an exception if it's not there."""
+ return self._getAttrMap()[key]
+
+ def __iter__(self):
+ "Iterating over a tag iterates over its contents."
+ return iter(self.contents)
+
+ def __len__(self):
+ "The length of a tag is the length of its list of contents."
+ return len(self.contents)
+
+ def __contains__(self, x):
+ return x in self.contents
+
+ def __nonzero__(self):
+ "A tag is non-None even if it has no contents."
+ return True
+
+ def __setitem__(self, key, value):
+ """Setting tag[key] sets the value of the 'key' attribute for the
+ tag."""
+ self._getAttrMap()
+ self.attrMap[key] = value
+ found = False
+ for i in range(0, len(self.attrs)):
+ if self.attrs[i][0] == key:
+ self.attrs[i] = (key, value)
+ found = True
+ if not found:
+ self.attrs.append((key, value))
+ self._getAttrMap()[key] = value
+
+ def __delitem__(self, key):
+ "Deleting tag[key] deletes all 'key' attributes for the tag."
+ for item in self.attrs:
+ if item[0] == key:
+ self.attrs.remove(item)
+ #We don't break because bad HTML can define the same
+ #attribute multiple times.
+ self._getAttrMap()
+ if self.attrMap.has_key(key):
+ del self.attrMap[key]
+
+ def __call__(self, *args, **kwargs):
+ """Calling a tag like a function is the same as calling its
+ findAll() method. Eg. tag('a') returns a list of all the A tags
+ found within this tag."""
+ return apply(self.findAll, args, kwargs)
+
+ def __getattr__(self, tag):
+ #print "Getattr %s.%s" % (self.__class__, tag)
+ if len(tag) > 3 and tag.rfind('Tag') == len(tag)-3:
+ return self.find(tag[:-3])
+ elif tag.find('__') != 0:
+ return self.find(tag)
+ raise AttributeError, "'%s' object has no attribute '%s'" % (self.__class__, tag)
+
+ def __eq__(self, other):
+ """Returns true iff this tag has the same name, the same attributes,
+ and the same contents (recursively) as the given tag.
+
+ NOTE: right now this will return false if two tags have the
+ same attributes in a different order. Should this be fixed?"""
+ if other is self:
+ return True
+ if not hasattr(other, 'name') or not hasattr(other, 'attrs') or not hasattr(other, 'contents') or self.name != other.name or self.attrs != other.attrs or len(self) != len(other):
+ return False
+ for i in range(0, len(self.contents)):
+ if self.contents[i] != other.contents[i]:
+ return False
+ return True
+
+ def __ne__(self, other):
+ """Returns true iff this tag is not identical to the other tag,
+ as defined in __eq__."""
+ return not self == other
+
+ def __repr__(self, encoding=DEFAULT_OUTPUT_ENCODING):
+ """Renders this tag as a string."""
+ return self.__str__(encoding)
+
+ def __unicode__(self):
+ return self.__str__(None)
+
+ def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING,
+ prettyPrint=False, indentLevel=0):
+ """Returns a string or Unicode representation of this tag and
+ its contents. To get Unicode, pass None for encoding.
+
+ NOTE: since Python's HTML parser consumes whitespace, this
+ method is not certain to reproduce the whitespace present in
+ the original string."""
+
+ encodedName = self.toEncoding(self.name, encoding)
+
+ attrs = []
+ if self.attrs:
+ for key, val in self.attrs:
+ fmt = '%s="%s"'
+ if isinstance(val, basestring):
+ if self.containsSubstitutions and '%SOUP-ENCODING%' in val:
+ val = self.substituteEncoding(val, encoding)
+
+ # The attribute value either:
+ #
+ # * Contains no embedded double quotes or single quotes.
+ # No problem: we enclose it in double quotes.
+ # * Contains embedded single quotes. No problem:
+ # double quotes work here too.
+ # * Contains embedded double quotes. No problem:
+ # we enclose it in single quotes.
+ # * Embeds both single _and_ double quotes. This
+ # can't happen naturally, but it can happen if
+ # you modify an attribute value after parsing
+ # the document. Now we have a bit of a
+ # problem. We solve it by enclosing the
+ # attribute in single quotes, and escaping any
+ # embedded single quotes to XML entities.
+ if '"' in val:
+ fmt = "%s='%s'"
+ if "'" in val:
+ # TODO: replace with apos when
+ # appropriate.
+ val = val.replace("'", "&squot;")
+
+ # Now we're okay w/r/t quotes. But the attribute
+ # value might also contain angle brackets, or
+ # ampersands that aren't part of entities. We need
+ # to escape those to XML entities too.
+ val = self.BARE_AMPERSAND_OR_BRACKET.sub(self._sub_entity, val)
+
+ attrs.append(fmt % (self.toEncoding(key, encoding),
+ self.toEncoding(val, encoding)))
+ close = ''
+ closeTag = ''
+ if self.isSelfClosing:
+ close = ' /'
+ else:
+ closeTag = '</%s>' % encodedName
+
+ indentTag, indentContents = 0, 0
+ if prettyPrint:
+ indentTag = indentLevel
+ space = (' ' * (indentTag-1))
+ indentContents = indentTag + 1
+ contents = self.renderContents(encoding, prettyPrint, indentContents)
+ if self.hidden:
+ s = contents
+ else:
+ s = []
+ attributeString = ''
+ if attrs:
+ attributeString = ' ' + ' '.join(attrs)
+ if prettyPrint:
+ s.append(space)
+ s.append('<%s%s%s>' % (encodedName, attributeString, close))
+ if prettyPrint:
+ s.append("\n")
+ s.append(contents)
+ if prettyPrint and contents and contents[-1] != "\n":
+ s.append("\n")
+ if prettyPrint and closeTag:
+ s.append(space)
+ s.append(closeTag)
+ if prettyPrint and closeTag and self.nextSibling:
+ s.append("\n")
+ s = ''.join(s)
+ return s
+
+ def decompose(self):
+ """Recursively destroys the contents of this tree."""
+ self.extract()
+ if len(self.contents) == 0:
+ return
+ current = self.contents[0]
+ while current is not None:
+ next = current.next
+ if isinstance(current, Tag):
+ del current.contents[:]
+ current.parent = None
+ current.previous = None
+ current.previousSibling = None
+ current.next = None
+ current.nextSibling = None
+ current = next
+
+ def prettify(self, encoding=DEFAULT_OUTPUT_ENCODING):
+ return self.__str__(encoding, True)
+
+ def renderContents(self, encoding=DEFAULT_OUTPUT_ENCODING,
+ prettyPrint=False, indentLevel=0):
+ """Renders the contents of this tag as a string in the given
+ encoding. If encoding is None, returns a Unicode string.."""
+ s=[]
+ for c in self:
+ text = None
+ if isinstance(c, NavigableString):
+ text = c.__str__(encoding)
+ elif isinstance(c, Tag):
+ s.append(c.__str__(encoding, prettyPrint, indentLevel))
+ if text and prettyPrint:
+ text = text.strip()
+ if text:
+ if prettyPrint:
+ s.append(" " * (indentLevel-1))
+ s.append(text)
+ if prettyPrint:
+ s.append("\n")
+ return ''.join(s)
+
+ #Soup methods
+
+ def find(self, name=None, attrs={}, recursive=True, text=None,
+ **kwargs):
+ """Return only the first child of this Tag matching the given
+ criteria."""
+ r = None
+ l = self.findAll(name, attrs, recursive, text, 1, **kwargs)
+ if l:
+ r = l[0]
+ return r
+ findChild = find
+
+ def findAll(self, name=None, attrs={}, recursive=True, text=None,
+ limit=None, **kwargs):
+ """Extracts a list of Tag objects that match the given
+ criteria. You can specify the name of the Tag and any
+ attributes you want the Tag to have.
+
+ The value of a key-value pair in the 'attrs' map can be a
+ string, a list of strings, a regular expression object, or a
+ callable that takes a string and returns whether or not the
+ string matches for some custom definition of 'matches'. The
+ same is true of the tag name."""
+ generator = self.recursiveChildGenerator
+ if not recursive:
+ generator = self.childGenerator
+ return self._findAll(name, attrs, text, limit, generator, **kwargs)
+ findChildren = findAll
+
+ # Pre-3.x compatibility methods
+ first = find
+ fetch = findAll
+
+ def fetchText(self, text=None, recursive=True, limit=None):
+ return self.findAll(text=text, recursive=recursive, limit=limit)
+
+ def firstText(self, text=None, recursive=True):
+ return self.find(text=text, recursive=recursive)
+
+ #Private methods
+
+ def _getAttrMap(self):
+ """Initializes a map representation of this tag's attributes,
+ if not already initialized."""
+ if not getattr(self, 'attrMap'):
+ self.attrMap = {}
+ for (key, value) in self.attrs:
+ self.attrMap[key] = value
+ return self.attrMap
+
+ #Generator methods
+ def childGenerator(self):
+ # Just use the iterator from the contents
+ return iter(self.contents)
+
+ def recursiveChildGenerator(self):
+ if not len(self.contents):
+ raise StopIteration
+ stopNode = self._lastRecursiveChild().next
+ current = self.contents[0]
+ while current is not stopNode:
+ yield current
+ current = current.next
+
+
+# Next, a couple classes to represent queries and their results.
+class SoupStrainer:
+ """Encapsulates a number of ways of matching a markup element (tag or
+ text)."""
+
+ def __init__(self, name=None, attrs={}, text=None, **kwargs):
+ self.name = name
+ if isinstance(attrs, basestring):
+ kwargs['class'] = _match_css_class(attrs)
+ attrs = None
+ if kwargs:
+ if attrs:
+ attrs = attrs.copy()
+ attrs.update(kwargs)
+ else:
+ attrs = kwargs
+ self.attrs = attrs
+ self.text = text
+
+ def __str__(self):
+ if self.text:
+ return self.text
+ else:
+ return "%s|%s" % (self.name, self.attrs)
+
+ def searchTag(self, markupName=None, markupAttrs={}):
+ found = None
+ markup = None
+ if isinstance(markupName, Tag):
+ markup = markupName
+ markupAttrs = markup
+ callFunctionWithTagData = callable(self.name) \
+ and not isinstance(markupName, Tag)
+
+ if (not self.name) \
+ or callFunctionWithTagData \
+ or (markup and self._matches(markup, self.name)) \
+ or (not markup and self._matches(markupName, self.name)):
+ if callFunctionWithTagData:
+ match = self.name(markupName, markupAttrs)
+ else:
+ match = True
+ markupAttrMap = None
+ for attr, matchAgainst in self.attrs.items():
+ if not markupAttrMap:
+ if hasattr(markupAttrs, 'get'):
+ markupAttrMap = markupAttrs
+ else:
+ markupAttrMap = {}
+ for k,v in markupAttrs:
+ markupAttrMap[k] = v
+ attrValue = markupAttrMap.get(attr)
+ if not self._matches(attrValue, matchAgainst):
+ match = False
+ break
+ if match:
+ if markup:
+ found = markup
+ else:
+ found = markupName
+ return found
+
+ def search(self, markup):
+ #print 'looking for %s in %s' % (self, markup)
+ found = None
+ # If given a list of items, scan it for a text element that
+ # matches.
+ if hasattr(markup, "__iter__") \
+ and not isinstance(markup, Tag):
+ for element in markup:
+ if isinstance(element, NavigableString) \
+ and self.search(element):
+ found = element
+ break
+ # If it's a Tag, make sure its name or attributes match.
+ # Don't bother with Tags if we're searching for text.
+ elif isinstance(markup, Tag):
+ if not self.text:
+ found = self.searchTag(markup)
+ # If it's text, make sure the text matches.
+ elif isinstance(markup, NavigableString) or \
+ isinstance(markup, basestring):
+ if self._matches(markup, self.text):
+ found = markup
+ else:
+ raise Exception, "I don't know how to match against a %s" \
+ % markup.__class__
+ return found
+
+ def _matches(self, markup, matchAgainst):
+ #print "Matching %s against %s" % (markup, matchAgainst)
+ result = False
+ if matchAgainst is True:
+ result = markup is not None
+ elif callable(matchAgainst):
+ result = matchAgainst(markup)
+ else:
+ #Custom match methods take the tag as an argument, but all
+ #other ways of matching match the tag name as a string.
+ if isinstance(markup, Tag):
+ markup = markup.name
+ if markup and not isinstance(markup, basestring):
+ markup = unicode(markup)
+ #Now we know that chunk is either a string, or None.
+ if hasattr(matchAgainst, 'match'):
+ # It's a regexp object.
+ result = markup and matchAgainst.search(markup)
+ elif hasattr(matchAgainst, '__iter__'): # list-like
+ result = markup in matchAgainst
+ elif hasattr(matchAgainst, 'items'):
+ result = markup.has_key(matchAgainst)
+ elif matchAgainst and isinstance(markup, basestring):
+ if isinstance(markup, unicode):
+ matchAgainst = unicode(matchAgainst)
+ else:
+ matchAgainst = str(matchAgainst)
+
+ if not result:
+ result = matchAgainst == markup
+ return result
+
+class ResultSet(list):
+ """A ResultSet is just a list that keeps track of the SoupStrainer
+ that created it."""
+ def __init__(self, source):
+ list.__init__([])
+ self.source = source
+
+# Now, some helper functions.
+
+def buildTagMap(default, *args):
+ """Turns a list of maps, lists, or scalars into a single map.
+ Used to build the SELF_CLOSING_TAGS, NESTABLE_TAGS, and
+ NESTING_RESET_TAGS maps out of lists and partial maps."""
+ built = {}
+ for portion in args:
+ if hasattr(portion, 'items'):
+ #It's a map. Merge it.
+ for k,v in portion.items():
+ built[k] = v
+ elif hasattr(portion, '__iter__'): # is a list
+ #It's a list. Map each item to the default.
+ for k in portion:
+ built[k] = default
+ else:
+ #It's a scalar. Map it to the default.
+ built[portion] = default
+ return built
+
+# Now, the parser classes.
+
+class BeautifulStoneSoup(Tag, SGMLParser):
+
+ """This class contains the basic parser and search code. It defines
+ a parser that knows nothing about tag behavior except for the
+ following:
+
+ You can't close a tag without closing all the tags it encloses.
+ That is, "<foo><bar></foo>" actually means
+ "<foo><bar></bar></foo>".
+
+ [Another possible explanation is "<foo><bar /></foo>", but since
+ this class defines no SELF_CLOSING_TAGS, it will never use that
+ explanation.]
+
+ This class is useful for parsing XML or made-up markup languages,
+ or when BeautifulSoup makes an assumption counter to what you were
+ expecting."""
+
+ SELF_CLOSING_TAGS = {}
+ NESTABLE_TAGS = {}
+ RESET_NESTING_TAGS = {}
+ QUOTE_TAGS = {}
+ PRESERVE_WHITESPACE_TAGS = []
+
+ MARKUP_MASSAGE = [(re.compile('(<[^<>]*)/>'),
+ lambda x: x.group(1) + ' />'),
+ (re.compile('<!\s+([^<>]*)>'),
+ lambda x: '<!' + x.group(1) + '>')
+ ]
+
+ ROOT_TAG_NAME = u'[document]'
+
+ HTML_ENTITIES = "html"
+ XML_ENTITIES = "xml"
+ XHTML_ENTITIES = "xhtml"
+ # TODO: This only exists for backwards-compatibility
+ ALL_ENTITIES = XHTML_ENTITIES
+
+ # Used when determining whether a text node is all whitespace and
+ # can be replaced with a single space. A text node that contains
+ # fancy Unicode spaces (usually non-breaking) should be left
+ # alone.
+ STRIP_ASCII_SPACES = { 9: None, 10: None, 12: None, 13: None, 32: None, }
+
+ def __init__(self, markup="", parseOnlyThese=None, fromEncoding=None,
+ markupMassage=True, smartQuotesTo=XML_ENTITIES,
+ convertEntities=None, selfClosingTags=None, isHTML=False):
+ """The Soup object is initialized as the 'root tag', and the
+ provided markup (which can be a string or a file-like object)
+ is fed into the underlying parser.
+
+ sgmllib will process most bad HTML, and the BeautifulSoup
+ class has some tricks for dealing with some HTML that kills
+ sgmllib, but Beautiful Soup can nonetheless choke or lose data
+ if your data uses self-closing tags or declarations
+ incorrectly.
+
+ By default, Beautiful Soup uses regexes to sanitize input,
+ avoiding the vast majority of these problems. If the problems
+ don't apply to you, pass in False for markupMassage, and
+ you'll get better performance.
+
+ The default parser massage techniques fix the two most common
+ instances of invalid HTML that choke sgmllib:
+
+ <br/> (No space between name of closing tag and tag close)
+ <! --Comment--> (Extraneous whitespace in declaration)
+
+ You can pass in a custom list of (RE object, replace method)
+ tuples to get Beautiful Soup to scrub your input the way you
+ want."""
+
+ self.parseOnlyThese = parseOnlyThese
+ self.fromEncoding = fromEncoding
+ self.smartQuotesTo = smartQuotesTo
+ self.convertEntities = convertEntities
+ # Set the rules for how we'll deal with the entities we
+ # encounter
+ if self.convertEntities:
+ # It doesn't make sense to convert encoded characters to
+ # entities even while you're converting entities to Unicode.
+ # Just convert it all to Unicode.
+ self.smartQuotesTo = None
+ if convertEntities == self.HTML_ENTITIES:
+ self.convertXMLEntities = False
+ self.convertHTMLEntities = True
+ self.escapeUnrecognizedEntities = True
+ elif convertEntities == self.XHTML_ENTITIES:
+ self.convertXMLEntities = True
+ self.convertHTMLEntities = True
+ self.escapeUnrecognizedEntities = False
+ elif convertEntities == self.XML_ENTITIES:
+ self.convertXMLEntities = True
+ self.convertHTMLEntities = False
+ self.escapeUnrecognizedEntities = False
+ else:
+ self.convertXMLEntities = False
+ self.convertHTMLEntities = False
+ self.escapeUnrecognizedEntities = False
+
+ self.instanceSelfClosingTags = buildTagMap(None, selfClosingTags)
+ SGMLParser.__init__(self)
+
+ if hasattr(markup, 'read'): # It's a file-type object.
+ markup = markup.read()
+ self.markup = markup
+ self.markupMassage = markupMassage
+ try:
+ self._feed(isHTML=isHTML)
+ except StopParsing:
+ pass
+ self.markup = None # The markup can now be GCed
+
+ def convert_charref(self, name):
+ """This method fixes a bug in Python's SGMLParser."""
+ try:
+ n = int(name)
+ except ValueError:
+ return
+ if not 0 <= n <= 127 : # ASCII ends at 127, not 255
+ return
+ return self.convert_codepoint(n)
+
+ def _feed(self, inDocumentEncoding=None, isHTML=False):
+ # Convert the document to Unicode.
+ markup = self.markup
+ if isinstance(markup, unicode):
+ if not hasattr(self, 'originalEncoding'):
+ self.originalEncoding = None
+ else:
+ dammit = UnicodeDammit\
+ (markup, [self.fromEncoding, inDocumentEncoding],
+ smartQuotesTo=self.smartQuotesTo, isHTML=isHTML)
+ markup = dammit.unicode
+ self.originalEncoding = dammit.originalEncoding
+ self.declaredHTMLEncoding = dammit.declaredHTMLEncoding
+ if markup:
+ if self.markupMassage:
+ if not hasattr(self.markupMassage, "__iter__"):
+ self.markupMassage = self.MARKUP_MASSAGE
+ for fix, m in self.markupMassage:
+ markup = fix.sub(m, markup)
+ # TODO: We get rid of markupMassage so that the
+ # soup object can be deepcopied later on. Some
+ # Python installations can't copy regexes. If anyone
+ # was relying on the existence of markupMassage, this
+ # might cause problems.
+ del(self.markupMassage)
+ self.reset()
+
+ SGMLParser.feed(self, markup)
+ # Close out any unfinished strings and close all the open tags.
+ self.endData()
+ while self.currentTag.name != self.ROOT_TAG_NAME:
+ self.popTag()
+
+ def __getattr__(self, methodName):
+ """This method routes method call requests to either the SGMLParser
+ superclass or the Tag superclass, depending on the method name."""
+ #print "__getattr__ called on %s.%s" % (self.__class__, methodName)
+
+ if methodName.startswith('start_') or methodName.startswith('end_') \
+ or methodName.startswith('do_'):
+ return SGMLParser.__getattr__(self, methodName)
+ elif not methodName.startswith('__'):
+ return Tag.__getattr__(self, methodName)
+ else:
+ raise AttributeError
+
+ def isSelfClosingTag(self, name):
+ """Returns true iff the given string is the name of a
+ self-closing tag according to this parser."""
+ return self.SELF_CLOSING_TAGS.has_key(name) \
+ or self.instanceSelfClosingTags.has_key(name)
+
+ def reset(self):
+ Tag.__init__(self, self, self.ROOT_TAG_NAME)
+ self.hidden = 1
+ SGMLParser.reset(self)
+ self.currentData = []
+ self.currentTag = None
+ self.tagStack = []
+ self.quoteStack = []
+ self.pushTag(self)
+
+ def popTag(self):
+ tag = self.tagStack.pop()
+
+ #print "Pop", tag.name
+ if self.tagStack:
+ self.currentTag = self.tagStack[-1]
+ return self.currentTag
+
+ def pushTag(self, tag):
+ #print "Push", tag.name
+ if self.currentTag:
+ self.currentTag.contents.append(tag)
+ self.tagStack.append(tag)
+ self.currentTag = self.tagStack[-1]
+
+ def endData(self, containerClass=NavigableString):
+ if self.currentData:
+ currentData = u''.join(self.currentData)
+ if (currentData.translate(self.STRIP_ASCII_SPACES) == '' and
+ not set([tag.name for tag in self.tagStack]).intersection(
+ self.PRESERVE_WHITESPACE_TAGS)):
+ if '\n' in currentData:
+ currentData = '\n'
+ else:
+ currentData = ' '
+ self.currentData = []
+ if self.parseOnlyThese and len(self.tagStack) <= 1 and \
+ (not self.parseOnlyThese.text or \
+ not self.parseOnlyThese.search(currentData)):
+ return
+ o = containerClass(currentData)
+ o.setup(self.currentTag, self.previous)
+ if self.previous:
+ self.previous.next = o
+ self.previous = o
+ self.currentTag.contents.append(o)
+
+
+ def _popToTag(self, name, inclusivePop=True):
+ """Pops the tag stack up to and including the most recent
+ instance of the given tag. If inclusivePop is false, pops the tag
+ stack up to but *not* including the most recent instqance of
+ the given tag."""
+ #print "Popping to %s" % name
+ if name == self.ROOT_TAG_NAME:
+ return
+
+ numPops = 0
+ mostRecentTag = None
+ for i in range(len(self.tagStack)-1, 0, -1):
+ if name == self.tagStack[i].name:
+ numPops = len(self.tagStack)-i
+ break
+ if not inclusivePop:
+ numPops = numPops - 1
+
+ for i in range(0, numPops):
+ mostRecentTag = self.popTag()
+ return mostRecentTag
+
+ def _smartPop(self, name):
+
+ """We need to pop up to the previous tag of this type, unless
+ one of this tag's nesting reset triggers comes between this
+ tag and the previous tag of this type, OR unless this tag is a
+ generic nesting trigger and another generic nesting trigger
+ comes between this tag and the previous tag of this type.
+
+ Examples:
+ <p>Foo<b>Bar *<p>* should pop to 'p', not 'b'.
+ <p>Foo<table>Bar *<p>* should pop to 'table', not 'p'.
+ <p>Foo<table><tr>Bar *<p>* should pop to 'tr', not 'p'.
+
+ <li><ul><li> *<li>* should pop to 'ul', not the first 'li'.
+ <tr><table><tr> *<tr>* should pop to 'table', not the first 'tr'
+ <td><tr><td> *<td>* should pop to 'tr', not the first 'td'
+ """
+
+ nestingResetTriggers = self.NESTABLE_TAGS.get(name)
+ isNestable = nestingResetTriggers != None
+ isResetNesting = self.RESET_NESTING_TAGS.has_key(name)
+ popTo = None
+ inclusive = True
+ for i in range(len(self.tagStack)-1, 0, -1):
+ p = self.tagStack[i]
+ if (not p or p.name == name) and not isNestable:
+ #Non-nestable tags get popped to the top or to their
+ #last occurance.
+ popTo = name
+ break
+ if (nestingResetTriggers is not None
+ and p.name in nestingResetTriggers) \
+ or (nestingResetTriggers is None and isResetNesting
+ and self.RESET_NESTING_TAGS.has_key(p.name)):
+
+ #If we encounter one of the nesting reset triggers
+ #peculiar to this tag, or we encounter another tag
+ #that causes nesting to reset, pop up to but not
+ #including that tag.
+ popTo = p.name
+ inclusive = False
+ break
+ p = p.parent
+ if popTo:
+ self._popToTag(popTo, inclusive)
+
+ def unknown_starttag(self, name, attrs, selfClosing=0):
+ #print "Start tag %s: %s" % (name, attrs)
+ if self.quoteStack:
+ #This is not a real tag.
+ #print "<%s> is not real!" % name
+ attrs = ''.join([' %s="%s"' % (x, y) for x, y in attrs])
+ self.handle_data('<%s%s>' % (name, attrs))
+ return
+ self.endData()
+
+ if not self.isSelfClosingTag(name) and not selfClosing:
+ self._smartPop(name)
+
+ if self.parseOnlyThese and len(self.tagStack) <= 1 \
+ and (self.parseOnlyThese.text or not self.parseOnlyThese.searchTag(name, attrs)):
+ return
+
+ tag = Tag(self, name, attrs, self.currentTag, self.previous)
+ if self.previous:
+ self.previous.next = tag
+ self.previous = tag
+ self.pushTag(tag)
+ if selfClosing or self.isSelfClosingTag(name):
+ self.popTag()
+ if name in self.QUOTE_TAGS:
+ #print "Beginning quote (%s)" % name
+ self.quoteStack.append(name)
+ self.literal = 1
+ return tag
+
+ def unknown_endtag(self, name):
+ #print "End tag %s" % name
+ if self.quoteStack and self.quoteStack[-1] != name:
+ #This is not a real end tag.
+ #print "</%s> is not real!" % name
+ self.handle_data('</%s>' % name)
+ return
+ self.endData()
+ self._popToTag(name)
+ if self.quoteStack and self.quoteStack[-1] == name:
+ self.quoteStack.pop()
+ self.literal = (len(self.quoteStack) > 0)
+
+ def handle_data(self, data):
+ self.currentData.append(data)
+
+ def _toStringSubclass(self, text, subclass):
+ """Adds a certain piece of text to the tree as a NavigableString
+ subclass."""
+ self.endData()
+ self.handle_data(text)
+ self.endData(subclass)
+
+ def handle_pi(self, text):
+ """Handle a processing instruction as a ProcessingInstruction
+ object, possibly one with a %SOUP-ENCODING% slot into which an
+ encoding will be plugged later."""
+ if text[:3] == "xml":
+ text = u"xml version='1.0' encoding='%SOUP-ENCODING%'"
+ self._toStringSubclass(text, ProcessingInstruction)
+
+ def handle_comment(self, text):
+ "Handle comments as Comment objects."
+ self._toStringSubclass(text, Comment)
+
+ def handle_charref(self, ref):
+ "Handle character references as data."
+ if self.convertEntities:
+ data = unichr(int(ref))
+ else:
+ data = '&#%s;' % ref
+ self.handle_data(data)
+
+ def handle_entityref(self, ref):
+ """Handle entity references as data, possibly converting known
+ HTML and/or XML entity references to the corresponding Unicode
+ characters."""
+ data = None
+ if self.convertHTMLEntities:
+ try:
+ data = unichr(name2codepoint[ref])
+ except KeyError:
+ pass
+
+ if not data and self.convertXMLEntities:
+ data = self.XML_ENTITIES_TO_SPECIAL_CHARS.get(ref)
+
+ if not data and self.convertHTMLEntities and \
+ not self.XML_ENTITIES_TO_SPECIAL_CHARS.get(ref):
+ # TODO: We've got a problem here. We're told this is
+ # an entity reference, but it's not an XML entity
+ # reference or an HTML entity reference. Nonetheless,
+ # the logical thing to do is to pass it through as an
+ # unrecognized entity reference.
+ #
+ # Except: when the input is "&carol;" this function
+ # will be called with input "carol". When the input is
+ # "AT&T", this function will be called with input
+ # "T". We have no way of knowing whether a semicolon
+ # was present originally, so we don't know whether
+ # this is an unknown entity or just a misplaced
+ # ampersand.
+ #
+ # The more common case is a misplaced ampersand, so I
+ # escape the ampersand and omit the trailing semicolon.
+ data = "&amp;%s" % ref
+ if not data:
+ # This case is different from the one above, because we
+ # haven't already gone through a supposedly comprehensive
+ # mapping of entities to Unicode characters. We might not
+ # have gone through any mapping at all. So the chances are
+ # very high that this is a real entity, and not a
+ # misplaced ampersand.
+ data = "&%s;" % ref
+ self.handle_data(data)
+
+ def handle_decl(self, data):
+ "Handle DOCTYPEs and the like as Declaration objects."
+ self._toStringSubclass(data, Declaration)
+
+ def parse_declaration(self, i):
+ """Treat a bogus SGML declaration as raw data. Treat a CDATA
+ declaration as a CData object."""
+ j = None
+ if self.rawdata[i:i+9] == '<![CDATA[':
+ k = self.rawdata.find(']]>', i)
+ if k == -1:
+ k = len(self.rawdata)
+ data = self.rawdata[i+9:k]
+ j = k+3
+ self._toStringSubclass(data, CData)
+ else:
+ try:
+ j = SGMLParser.parse_declaration(self, i)
+ except SGMLParseError:
+ toHandle = self.rawdata[i:]
+ self.handle_data(toHandle)
+ j = i + len(toHandle)
+ return j
+
+class BeautifulSoup(BeautifulStoneSoup):
+
+ """This parser knows the following facts about HTML:
+
+ * Some tags have no closing tag and should be interpreted as being
+ closed as soon as they are encountered.
+
+ * The text inside some tags (ie. 'script') may contain tags which
+ are not really part of the document and which should be parsed
+ as text, not tags. If you want to parse the text as tags, you can
+ always fetch it and parse it explicitly.
+
+ * Tag nesting rules:
+
+ Most tags can't be nested at all. For instance, the occurance of
+ a <p> tag should implicitly close the previous <p> tag.
+
+ <p>Para1<p>Para2
+ should be transformed into:
+ <p>Para1</p><p>Para2
+
+ Some tags can be nested arbitrarily. For instance, the occurance
+ of a <blockquote> tag should _not_ implicitly close the previous
+ <blockquote> tag.
+
+ Alice said: <blockquote>Bob said: <blockquote>Blah
+ should NOT be transformed into:
+ Alice said: <blockquote>Bob said: </blockquote><blockquote>Blah
+
+ Some tags can be nested, but the nesting is reset by the
+ interposition of other tags. For instance, a <tr> tag should
+ implicitly close the previous <tr> tag within the same <table>,
+ but not close a <tr> tag in another table.
+
+ <table><tr>Blah<tr>Blah
+ should be transformed into:
+ <table><tr>Blah</tr><tr>Blah
+ but,
+ <tr>Blah<table><tr>Blah
+ should NOT be transformed into
+ <tr>Blah<table></tr><tr>Blah
+
+ Differing assumptions about tag nesting rules are a major source
+ of problems with the BeautifulSoup class. If BeautifulSoup is not
+ treating as nestable a tag your page author treats as nestable,
+ try ICantBelieveItsBeautifulSoup, MinimalSoup, or
+ BeautifulStoneSoup before writing your own subclass."""
+
+ def __init__(self, *args, **kwargs):
+ if not kwargs.has_key('smartQuotesTo'):
+ kwargs['smartQuotesTo'] = self.HTML_ENTITIES
+ kwargs['isHTML'] = True
+ BeautifulStoneSoup.__init__(self, *args, **kwargs)
+
+ SELF_CLOSING_TAGS = buildTagMap(None,
+ ('br' , 'hr', 'input', 'img', 'meta',
+ 'spacer', 'link', 'frame', 'base', 'col'))
+
+ PRESERVE_WHITESPACE_TAGS = set(['pre', 'textarea'])
+
+ QUOTE_TAGS = {'script' : None, 'textarea' : None}
+
+ #According to the HTML standard, each of these inline tags can
+ #contain another tag of the same type. Furthermore, it's common
+ #to actually use these tags this way.
+ NESTABLE_INLINE_TAGS = ('span', 'font', 'q', 'object', 'bdo', 'sub', 'sup',
+ 'center')
+
+ #According to the HTML standard, these block tags can contain
+ #another tag of the same type. Furthermore, it's common
+ #to actually use these tags this way.
+ NESTABLE_BLOCK_TAGS = ('blockquote', 'div', 'fieldset', 'ins', 'del')
+
+ #Lists can contain other lists, but there are restrictions.
+ NESTABLE_LIST_TAGS = { 'ol' : [],
+ 'ul' : [],
+ 'li' : ['ul', 'ol'],
+ 'dl' : [],
+ 'dd' : ['dl'],
+ 'dt' : ['dl'] }
+
+ #Tables can contain other tables, but there are restrictions.
+ NESTABLE_TABLE_TAGS = {'table' : [],
+ 'tr' : ['table', 'tbody', 'tfoot', 'thead'],
+ 'td' : ['tr'],
+ 'th' : ['tr'],
+ 'thead' : ['table'],
+ 'tbody' : ['table'],
+ 'tfoot' : ['table'],
+ }
+
+ NON_NESTABLE_BLOCK_TAGS = ('address', 'form', 'p', 'pre')
+
+ #If one of these tags is encountered, all tags up to the next tag of
+ #this type are popped.
+ RESET_NESTING_TAGS = buildTagMap(None, NESTABLE_BLOCK_TAGS, 'noscript',
+ NON_NESTABLE_BLOCK_TAGS,
+ NESTABLE_LIST_TAGS,
+ NESTABLE_TABLE_TAGS)
+
+ NESTABLE_TAGS = buildTagMap([], NESTABLE_INLINE_TAGS, NESTABLE_BLOCK_TAGS,
+ NESTABLE_LIST_TAGS, NESTABLE_TABLE_TAGS)
+
+ # Used to detect the charset in a META tag; see start_meta
+ CHARSET_RE = re.compile("((^|;)\s*charset=)([^;]*)", re.M)
+
+ def start_meta(self, attrs):
+ """Beautiful Soup can detect a charset included in a META tag,
+ try to convert the document to that charset, and re-parse the
+ document from the beginning."""
+ httpEquiv = None
+ contentType = None
+ contentTypeIndex = None
+ tagNeedsEncodingSubstitution = False
+
+ for i in range(0, len(attrs)):
+ key, value = attrs[i]
+ key = key.lower()
+ if key == 'http-equiv':
+ httpEquiv = value
+ elif key == 'content':
+ contentType = value
+ contentTypeIndex = i
+
+ if httpEquiv and contentType: # It's an interesting meta tag.
+ match = self.CHARSET_RE.search(contentType)
+ if match:
+ if (self.declaredHTMLEncoding is not None or
+ self.originalEncoding == self.fromEncoding):
+ # An HTML encoding was sniffed while converting
+ # the document to Unicode, or an HTML encoding was
+ # sniffed during a previous pass through the
+ # document, or an encoding was specified
+ # explicitly and it worked. Rewrite the meta tag.
+ def rewrite(match):
+ return match.group(1) + "%SOUP-ENCODING%"
+ newAttr = self.CHARSET_RE.sub(rewrite, contentType)
+ attrs[contentTypeIndex] = (attrs[contentTypeIndex][0],
+ newAttr)
+ tagNeedsEncodingSubstitution = True
+ else:
+ # This is our first pass through the document.
+ # Go through it again with the encoding information.
+ newCharset = match.group(3)
+ if newCharset and newCharset != self.originalEncoding:
+ self.declaredHTMLEncoding = newCharset
+ self._feed(self.declaredHTMLEncoding)
+ raise StopParsing
+ pass
+ tag = self.unknown_starttag("meta", attrs)
+ if tag and tagNeedsEncodingSubstitution:
+ tag.containsSubstitutions = True
+
+class StopParsing(Exception):
+ pass
+
+class ICantBelieveItsBeautifulSoup(BeautifulSoup):
+
+ """The BeautifulSoup class is oriented towards skipping over
+ common HTML errors like unclosed tags. However, sometimes it makes
+ errors of its own. For instance, consider this fragment:
+
+ <b>Foo<b>Bar</b></b>
+
+ This is perfectly valid (if bizarre) HTML. However, the
+ BeautifulSoup class will implicitly close the first b tag when it
+ encounters the second 'b'. It will think the author wrote
+ "<b>Foo<b>Bar", and didn't close the first 'b' tag, because
+ there's no real-world reason to bold something that's already
+ bold. When it encounters '</b></b>' it will close two more 'b'
+ tags, for a grand total of three tags closed instead of two. This
+ can throw off the rest of your document structure. The same is
+ true of a number of other tags, listed below.
+
+ It's much more common for someone to forget to close a 'b' tag
+ than to actually use nested 'b' tags, and the BeautifulSoup class
+ handles the common case. This class handles the not-co-common
+ case: where you can't believe someone wrote what they did, but
+ it's valid HTML and BeautifulSoup screwed up by assuming it
+ wouldn't be."""
+
+ I_CANT_BELIEVE_THEYRE_NESTABLE_INLINE_TAGS = \
+ ('em', 'big', 'i', 'small', 'tt', 'abbr', 'acronym', 'strong',
+ 'cite', 'code', 'dfn', 'kbd', 'samp', 'strong', 'var', 'b',
+ 'big')
+
+ I_CANT_BELIEVE_THEYRE_NESTABLE_BLOCK_TAGS = ('noscript',)
+
+ NESTABLE_TAGS = buildTagMap([], BeautifulSoup.NESTABLE_TAGS,
+ I_CANT_BELIEVE_THEYRE_NESTABLE_BLOCK_TAGS,
+ I_CANT_BELIEVE_THEYRE_NESTABLE_INLINE_TAGS)
+
+class MinimalSoup(BeautifulSoup):
+ """The MinimalSoup class is for parsing HTML that contains
+ pathologically bad markup. It makes no assumptions about tag
+ nesting, but it does know which tags are self-closing, that
+ <script> tags contain Javascript and should not be parsed, that
+ META tags may contain encoding information, and so on.
+
+ This also makes it better for subclassing than BeautifulStoneSoup
+ or BeautifulSoup."""
+
+ RESET_NESTING_TAGS = buildTagMap('noscript')
+ NESTABLE_TAGS = {}
+
+class BeautifulSOAP(BeautifulStoneSoup):
+ """This class will push a tag with only a single string child into
+ the tag's parent as an attribute. The attribute's name is the tag
+ name, and the value is the string child. An example should give
+ the flavor of the change:
+
+ <foo><bar>baz</bar></foo>
+ =>
+ <foo bar="baz"><bar>baz</bar></foo>
+
+ You can then access fooTag['bar'] instead of fooTag.barTag.string.
+
+ This is, of course, useful for scraping structures that tend to
+ use subelements instead of attributes, such as SOAP messages. Note
+ that it modifies its input, so don't print the modified version
+ out.
+
+ I'm not sure how many people really want to use this class; let me
+ know if you do. Mainly I like the name."""
+
+ def popTag(self):
+ if len(self.tagStack) > 1:
+ tag = self.tagStack[-1]
+ parent = self.tagStack[-2]
+ parent._getAttrMap()
+ if (isinstance(tag, Tag) and len(tag.contents) == 1 and
+ isinstance(tag.contents[0], NavigableString) and
+ not parent.attrMap.has_key(tag.name)):
+ parent[tag.name] = tag.contents[0]
+ BeautifulStoneSoup.popTag(self)
+
+#Enterprise class names! It has come to our attention that some people
+#think the names of the Beautiful Soup parser classes are too silly
+#and "unprofessional" for use in enterprise screen-scraping. We feel
+#your pain! For such-minded folk, the Beautiful Soup Consortium And
+#All-Night Kosher Bakery recommends renaming this file to
+#"RobustParser.py" (or, in cases of extreme enterprisiness,
+#"RobustParserBeanInterface.class") and using the following
+#enterprise-friendly class aliases:
+class RobustXMLParser(BeautifulStoneSoup):
+ pass
+class RobustHTMLParser(BeautifulSoup):
+ pass
+class RobustWackAssHTMLParser(ICantBelieveItsBeautifulSoup):
+ pass
+class RobustInsanelyWackAssHTMLParser(MinimalSoup):
+ pass
+class SimplifyingSOAPParser(BeautifulSOAP):
+ pass
+
+######################################################
+#
+# Bonus library: Unicode, Dammit
+#
+# This class forces XML data into a standard format (usually to UTF-8
+# or Unicode). It is heavily based on code from Mark Pilgrim's
+# Universal Feed Parser. It does not rewrite the XML or HTML to
+# reflect a new encoding: that happens in BeautifulStoneSoup.handle_pi
+# (XML) and BeautifulSoup.start_meta (HTML).
+
+# Autodetects character encodings.
+# Download from http://chardet.feedparser.org/
+try:
+ import chardet
+# import chardet.constants
+# chardet.constants._debug = 1
+except ImportError:
+ chardet = None
+
+# cjkcodecs and iconv_codec make Python know about more character encodings.
+# Both are available from http://cjkpython.i18n.org/
+# They're built in if you use Python 2.4.
+try:
+ import cjkcodecs.aliases
+except ImportError:
+ pass
+try:
+ import iconv_codec
+except ImportError:
+ pass
+
+class UnicodeDammit:
+ """A class for detecting the encoding of a *ML document and
+ converting it to a Unicode string. If the source encoding is
+ windows-1252, can replace MS smart quotes with their HTML or XML
+ equivalents."""
+
+ # This dictionary maps commonly seen values for "charset" in HTML
+ # meta tags to the corresponding Python codec names. It only covers
+ # values that aren't in Python's aliases and can't be determined
+ # by the heuristics in find_codec.
+ CHARSET_ALIASES = { "macintosh" : "mac-roman",
+ "x-sjis" : "shift-jis" }
+
+ def __init__(self, markup, overrideEncodings=[],
+ smartQuotesTo='xml', isHTML=False):
+ self.declaredHTMLEncoding = None
+ self.markup, documentEncoding, sniffedEncoding = \
+ self._detectEncoding(markup, isHTML)
+ self.smartQuotesTo = smartQuotesTo
+ self.triedEncodings = []
+ if markup == '' or isinstance(markup, unicode):
+ self.originalEncoding = None
+ self.unicode = unicode(markup)
+ return
+
+ u = None
+ for proposedEncoding in overrideEncodings:
+ u = self._convertFrom(proposedEncoding)
+ if u: break
+ if not u:
+ for proposedEncoding in (documentEncoding, sniffedEncoding):
+ u = self._convertFrom(proposedEncoding)
+ if u: break
+
+ # If no luck and we have auto-detection library, try that:
+ if not u and chardet and not isinstance(self.markup, unicode):
+ u = self._convertFrom(chardet.detect(self.markup)['encoding'])
+
+ # As a last resort, try utf-8 and windows-1252:
+ if not u:
+ for proposed_encoding in ("utf-8", "windows-1252"):
+ u = self._convertFrom(proposed_encoding)
+ if u: break
+
+ self.unicode = u
+ if not u: self.originalEncoding = None
+
+ def _subMSChar(self, orig):
+ """Changes a MS smart quote character to an XML or HTML
+ entity."""
+ sub = self.MS_CHARS.get(orig)
+ if isinstance(sub, tuple):
+ if self.smartQuotesTo == 'xml':
+ sub = '&#x%s;' % sub[1]
+ else:
+ sub = '&%s;' % sub[0]
+ return sub
+
+ def _convertFrom(self, proposed):
+ proposed = self.find_codec(proposed)
+ if not proposed or proposed in self.triedEncodings:
+ return None
+ self.triedEncodings.append(proposed)
+ markup = self.markup
+
+ # Convert smart quotes to HTML if coming from an encoding
+ # that might have them.
+ if self.smartQuotesTo and proposed.lower() in("windows-1252",
+ "iso-8859-1",
+ "iso-8859-2"):
+ markup = re.compile("([\x80-\x9f])").sub \
+ (lambda(x): self._subMSChar(x.group(1)),
+ markup)
+
+ try:
+ # print "Trying to convert document to %s" % proposed
+ u = self._toUnicode(markup, proposed)
+ self.markup = u
+ self.originalEncoding = proposed
+ except Exception, e:
+ # print "That didn't work!"
+ # print e
+ return None
+ #print "Correct encoding: %s" % proposed
+ return self.markup
+
+ def _toUnicode(self, data, encoding):
+ '''Given a string and its encoding, decodes the string into Unicode.
+ %encoding is a string recognized by encodings.aliases'''
+
+ # strip Byte Order Mark (if present)
+ if (len(data) >= 4) and (data[:2] == '\xfe\xff') \
+ and (data[2:4] != '\x00\x00'):
+ encoding = 'utf-16be'
+ data = data[2:]
+ elif (len(data) >= 4) and (data[:2] == '\xff\xfe') \
+ and (data[2:4] != '\x00\x00'):
+ encoding = 'utf-16le'
+ data = data[2:]
+ elif data[:3] == '\xef\xbb\xbf':
+ encoding = 'utf-8'
+ data = data[3:]
+ elif data[:4] == '\x00\x00\xfe\xff':
+ encoding = 'utf-32be'
+ data = data[4:]
+ elif data[:4] == '\xff\xfe\x00\x00':
+ encoding = 'utf-32le'
+ data = data[4:]
+ newdata = unicode(data, encoding)
+ return newdata
+
+ def _detectEncoding(self, xml_data, isHTML=False):
+ """Given a document, tries to detect its XML encoding."""
+ xml_encoding = sniffed_xml_encoding = None
+ try:
+ if xml_data[:4] == '\x4c\x6f\xa7\x94':
+ # EBCDIC
+ xml_data = self._ebcdic_to_ascii(xml_data)
+ elif xml_data[:4] == '\x00\x3c\x00\x3f':
+ # UTF-16BE
+ sniffed_xml_encoding = 'utf-16be'
+ xml_data = unicode(xml_data, 'utf-16be').encode('utf-8')
+ elif (len(xml_data) >= 4) and (xml_data[:2] == '\xfe\xff') \
+ and (xml_data[2:4] != '\x00\x00'):
+ # UTF-16BE with BOM
+ sniffed_xml_encoding = 'utf-16be'
+ xml_data = unicode(xml_data[2:], 'utf-16be').encode('utf-8')
+ elif xml_data[:4] == '\x3c\x00\x3f\x00':
+ # UTF-16LE
+ sniffed_xml_encoding = 'utf-16le'
+ xml_data = unicode(xml_data, 'utf-16le').encode('utf-8')
+ elif (len(xml_data) >= 4) and (xml_data[:2] == '\xff\xfe') and \
+ (xml_data[2:4] != '\x00\x00'):
+ # UTF-16LE with BOM
+ sniffed_xml_encoding = 'utf-16le'
+ xml_data = unicode(xml_data[2:], 'utf-16le').encode('utf-8')
+ elif xml_data[:4] == '\x00\x00\x00\x3c':
+ # UTF-32BE
+ sniffed_xml_encoding = 'utf-32be'
+ xml_data = unicode(xml_data, 'utf-32be').encode('utf-8')
+ elif xml_data[:4] == '\x3c\x00\x00\x00':
+ # UTF-32LE
+ sniffed_xml_encoding = 'utf-32le'
+ xml_data = unicode(xml_data, 'utf-32le').encode('utf-8')
+ elif xml_data[:4] == '\x00\x00\xfe\xff':
+ # UTF-32BE with BOM
+ sniffed_xml_encoding = 'utf-32be'
+ xml_data = unicode(xml_data[4:], 'utf-32be').encode('utf-8')
+ elif xml_data[:4] == '\xff\xfe\x00\x00':
+ # UTF-32LE with BOM
+ sniffed_xml_encoding = 'utf-32le'
+ xml_data = unicode(xml_data[4:], 'utf-32le').encode('utf-8')
+ elif xml_data[:3] == '\xef\xbb\xbf':
+ # UTF-8 with BOM
+ sniffed_xml_encoding = 'utf-8'
+ xml_data = unicode(xml_data[3:], 'utf-8').encode('utf-8')
+ else:
+ sniffed_xml_encoding = 'ascii'
+ pass
+ except:
+ xml_encoding_match = None
+ xml_encoding_match = re.compile(
+ '^<\?.*encoding=[\'"](.*?)[\'"].*\?>').match(xml_data)
+ if not xml_encoding_match and isHTML:
+ regexp = re.compile('<\s*meta[^>]+charset=([^>]*?)[;\'">]', re.I)
+ xml_encoding_match = regexp.search(xml_data)
+ if xml_encoding_match is not None:
+ xml_encoding = xml_encoding_match.groups()[0].lower()
+ if isHTML:
+ self.declaredHTMLEncoding = xml_encoding
+ if sniffed_xml_encoding and \
+ (xml_encoding in ('iso-10646-ucs-2', 'ucs-2', 'csunicode',
+ 'iso-10646-ucs-4', 'ucs-4', 'csucs4',
+ 'utf-16', 'utf-32', 'utf_16', 'utf_32',
+ 'utf16', 'u16')):
+ xml_encoding = sniffed_xml_encoding
+ return xml_data, xml_encoding, sniffed_xml_encoding
+
+
+ def find_codec(self, charset):
+ return self._codec(self.CHARSET_ALIASES.get(charset, charset)) \
+ or (charset and self._codec(charset.replace("-", ""))) \
+ or (charset and self._codec(charset.replace("-", "_"))) \
+ or charset
+
+ def _codec(self, charset):
+ if not charset: return charset
+ codec = None
+ try:
+ codecs.lookup(charset)
+ codec = charset
+ except (LookupError, ValueError):
+ pass
+ return codec
+
+ EBCDIC_TO_ASCII_MAP = None
+ def _ebcdic_to_ascii(self, s):
+ c = self.__class__
+ if not c.EBCDIC_TO_ASCII_MAP:
+ emap = (0,1,2,3,156,9,134,127,151,141,142,11,12,13,14,15,
+ 16,17,18,19,157,133,8,135,24,25,146,143,28,29,30,31,
+ 128,129,130,131,132,10,23,27,136,137,138,139,140,5,6,7,
+ 144,145,22,147,148,149,150,4,152,153,154,155,20,21,158,26,
+ 32,160,161,162,163,164,165,166,167,168,91,46,60,40,43,33,
+ 38,169,170,171,172,173,174,175,176,177,93,36,42,41,59,94,
+ 45,47,178,179,180,181,182,183,184,185,124,44,37,95,62,63,
+ 186,187,188,189,190,191,192,193,194,96,58,35,64,39,61,34,
+ 195,97,98,99,100,101,102,103,104,105,196,197,198,199,200,
+ 201,202,106,107,108,109,110,111,112,113,114,203,204,205,
+ 206,207,208,209,126,115,116,117,118,119,120,121,122,210,
+ 211,212,213,214,215,216,217,218,219,220,221,222,223,224,
+ 225,226,227,228,229,230,231,123,65,66,67,68,69,70,71,72,
+ 73,232,233,234,235,236,237,125,74,75,76,77,78,79,80,81,
+ 82,238,239,240,241,242,243,92,159,83,84,85,86,87,88,89,
+ 90,244,245,246,247,248,249,48,49,50,51,52,53,54,55,56,57,
+ 250,251,252,253,254,255)
+ import string
+ c.EBCDIC_TO_ASCII_MAP = string.maketrans( \
+ ''.join(map(chr, range(256))), ''.join(map(chr, emap)))
+ return s.translate(c.EBCDIC_TO_ASCII_MAP)
+
+ MS_CHARS = { '\x80' : ('euro', '20AC'),
+ '\x81' : ' ',
+ '\x82' : ('sbquo', '201A'),
+ '\x83' : ('fnof', '192'),
+ '\x84' : ('bdquo', '201E'),
+ '\x85' : ('hellip', '2026'),
+ '\x86' : ('dagger', '2020'),
+ '\x87' : ('Dagger', '2021'),
+ '\x88' : ('circ', '2C6'),
+ '\x89' : ('permil', '2030'),
+ '\x8A' : ('Scaron', '160'),
+ '\x8B' : ('lsaquo', '2039'),
+ '\x8C' : ('OElig', '152'),
+ '\x8D' : '?',
+ '\x8E' : ('#x17D', '17D'),
+ '\x8F' : '?',
+ '\x90' : '?',
+ '\x91' : ('lsquo', '2018'),
+ '\x92' : ('rsquo', '2019'),
+ '\x93' : ('ldquo', '201C'),
+ '\x94' : ('rdquo', '201D'),
+ '\x95' : ('bull', '2022'),
+ '\x96' : ('ndash', '2013'),
+ '\x97' : ('mdash', '2014'),
+ '\x98' : ('tilde', '2DC'),
+ '\x99' : ('trade', '2122'),
+ '\x9a' : ('scaron', '161'),
+ '\x9b' : ('rsaquo', '203A'),
+ '\x9c' : ('oelig', '153'),
+ '\x9d' : '?',
+ '\x9e' : ('#x17E', '17E'),
+ '\x9f' : ('Yuml', ''),}
+
+#######################################################################
+
+
+#By default, act as an HTML pretty-printer.
+if __name__ == '__main__':
+ import sys
+ soup = BeautifulSoup(sys.stdin)
+ print soup.prettify()
diff --git a/cgi/GeoIP.dat b/cgi/GeoIP.dat
new file mode 100644
index 0000000..b98993c
--- /dev/null
+++ b/cgi/GeoIP.dat
Binary files differ
diff --git a/cgi/anarkia.py b/cgi/anarkia.py
new file mode 100644
index 0000000..6b7e5fd
--- /dev/null
+++ b/cgi/anarkia.py
@@ -0,0 +1,439 @@
+# coding=utf-8
+import _mysql
+from database import *
+from framework import *
+from template import *
+from img import *
+from post import *
+from settings import Settings
+
+d_thread = {}
+d_post = {}
+
+def anarkia(self, path_split):
+ setBoard('anarkia')
+
+ if len(path_split) <= 2:
+ self.output = main()
+ return
+
+ raise UserError, 'Ya fue, baisano...'
+
+ if path_split[2] == 'opt':
+ self.output = boardoptions(self.formdata)
+ elif path_split[2] == 'mod':
+ self.output = mod(self.formdata)
+ elif path_split[2] == 'bans':
+ self.output = bans(self.formdata)
+ elif path_split[2] == 'css':
+ self.output = css(self.formdata)
+ elif path_split[2] == 'type':
+ self.output = type(self.formdata)
+ elif path_split[2] == 'emojis':
+ self.output = emojis(self.formdata)
+ else:
+ raise UserError, 'ke?'
+
+def main():
+ board = Settings._.BOARD
+
+ logs = FetchAll("SELECT * FROM `logs` WHERE `staff` = 'Anarko' ORDER BY `timestamp` DESC")
+ for log in logs:
+ log['timestamp_formatted'] = formatTimestamp(log['timestamp'])
+
+ return renderTemplate('anarkia.html', {'mode': 0, 'logs': logs})
+
+def type(formdata):
+ board = Settings._.BOARD
+
+ if board['board_type'] == '1':
+ (type_now, type_do, do_num) = ('BBS', 'IB', '0')
+ else:
+ (type_now, type_do, do_num) = ('IB', 'BBS', '1')
+
+ if formdata.get('transform') == 'do':
+ t = 0
+ try:
+ with open('anarkia_time') as f:
+ t = int(f.read())
+ except IOError:
+ pass
+
+ dif = time.time() - t
+ if dif > (10 * 60):
+ #if True:
+ import re
+ t = time.time()
+
+ board['board_type'] = do_num
+ board['force_css'] = Settings.HOME_URL + 'anarkia/style_' + type_do.lower() + '.css'
+ updateBoardSettings()
+
+ # update posts
+ fix_board()
+
+ # regenerate
+ setBoard('anarkia')
+ regenerateBoard(True)
+
+ tf = timeTaken(t, time.time())
+
+ with open('anarkia_time', 'w') as f:
+ t = f.write(str(int(time.time())))
+
+ msg = 'Cambiada estructura de sección a %s. (%s)' % (type_do, tf)
+ logAction(msg)
+ return renderTemplate('anarkia.html', {'mode': 99, 'msg': msg})
+ else:
+ raise UserError, 'Esta acción sólo se puede realizar cada 10 minutos. Faltan: %d mins.' % (10-int(dif/60))
+
+ return renderTemplate('anarkia.html', {'mode': 7, 'type_now': type_now, 'type_do': type_do})
+
+def fix_board():
+ board = Settings._.BOARD
+ get_fix_dictionary()
+
+ if board['board_type'] == '1':
+ to_fix = FetchAll("SELECT * FROM posts WHERE message LIKE '%%anarkia/res/%%' AND boardid = %s" % board['id'])
+ else:
+ to_fix = FetchAll("SELECT * FROM posts WHERE message LIKE '%%anarkia/read/%%' AND boardid = %s" % board['id'])
+
+ for p in to_fix:
+ try:
+ if board['board_type'] == '1':
+ newmessage = re.sub(r'/anarkia/res/(\d+).html#(\d+)">&gt;&gt;(\d+)', fix_to_bbs, p['message'])
+ else:
+ newmessage = re.sub(r'/anarkia/read/(\d+)/(\d+)">&gt;&gt;(\d+)', fix_to_ib, p['message'])
+
+ UpdateDb("UPDATE posts SET message = '%s' WHERE boardid = %s AND id = %s" % \
+ (_mysql.escape_string(newmessage), board['id'], p['id']))
+ except KeyError:
+ pass
+
+ return True
+
+def fix_to_bbs(matchobj):
+ threadid = matchobj.group(1)
+ pid = matchobj.group(2)
+ new_thread = d_thread[threadid]
+ new_post = d_post[new_thread][pid]
+ return '/anarkia/read/%s/%s">&gt;&gt;%s' % (new_thread, new_post, new_post)
+
+def fix_to_ib(matchobj):
+ threadid = matchobj.group(1)
+ num = int(matchobj.group(2))
+ new_thread = d_thread[threadid]
+ new_post = d_post[new_thread][num]
+ return '/anarkia/res/%s.html#%s">&gt;&gt;%s' % (new_thread, new_post, new_post)
+
+def get_fix_dictionary():
+ global d_thread, d_post
+ board = Settings._.BOARD
+ res = FetchAll("SELECT id, timestamp, parentid FROM posts WHERE boardid = %s ORDER BY CASE parentid WHEN 0 THEN id ELSE parentid END ASC, `id` ASC" % board['id'])
+ num = 1
+ thread = 0
+ for p in res:
+ pid = p['id']
+ if p['parentid'] == '0':
+ num = 1
+
+ time = p['timestamp']
+ if board['board_type'] == '1':
+ d_thread[pid] = time
+ thread = time
+ else:
+ d_thread[time] = pid
+ thread = pid
+
+ d_post[thread] = {}
+
+ if board['board_type'] == '1':
+ d_post[thread][pid] = num
+ else:
+ d_post[thread][num] = pid
+ num += 1
+
+ return
+
+def css(formdata):
+ board = Settings._.BOARD
+
+ if board['board_type'] == '1':
+ basename = 'style_bbs.css'
+ else:
+ basename = 'style_ib.css'
+
+ fname = '%sanarkia/%s' % (Settings.HOME_DIR, basename)
+
+ if formdata.get('cssfile'):
+ with open(fname, 'w') as f:
+ cssfile = f.write(formdata['cssfile'])
+
+ msg = 'CSS actualizado.'
+ logAction(msg)
+ return renderTemplate('anarkia.html', {'mode': 99, 'msg': msg})
+
+ with open(fname) as f:
+ cssfile = f.read()
+
+ return renderTemplate('anarkia.html', {'mode': 6, 'basename': basename, 'cssfile': cssfile})
+
+def bans(formdata):
+ board = Settings._.BOARD
+
+ if formdata.get('unban'):
+ unban = int(formdata['unban'])
+ boardpickle = pickle.dumps(['anarkia'])
+
+ ban = FetchOne("SELECT * FROM `bans` WHERE id = %d" % unban)
+ if not ban:
+ raise UserError, "Ban inválido."
+ if ban['boards'] != boardpickle:
+ raise USerError, "Ban inválido."
+
+ UpdateDb('DELETE FROM `bans` WHERE id = %s' % ban['id'])
+ logAction("Usuario %s desbaneado." % ban['ip'][:4])
+ regenerateAccess()
+
+ bans = FetchAll('SELECT * FROM `bans` WHERE staff = \'anarko\'')
+ for ban in bans:
+ ban['added'] = formatTimestamp(ban['added'])
+ if ban['until'] == '0':
+ ban['until'] = _('Does not expire')
+ else:
+ ban['until'] = formatTimestamp(ban['until'])
+ return renderTemplate('anarkia.html', {'mode': 5, 'bans': bans})
+
+def mod(formdata):
+ board = Settings._.BOARD
+
+ if formdata.get('thread'):
+ parentid = int(formdata['thread'])
+ posts = FetchAll('SELECT * FROM `posts` WHERE (parentid = %d OR id = %d) AND boardid = %s ORDER BY `id` ASC' % (parentid, parentid, board['id']))
+ return renderTemplate('anarkia.html', {'mode': 3, 'posts': posts})
+ elif formdata.get('lock'):
+ postid = int(formdata['lock'])
+ post = FetchOne('SELECT id, locked FROM posts WHERE boardid = %s AND id = %d AND parentid = 0 LIMIT 1' % (board['id'], postid))
+ if post['locked'] == '0':
+ setLocked = 1
+ msg = "Hilo %s cerrado." % post['id']
+ else:
+ setLocked = 0
+ msg = "Hilo %s abierto." % post['id']
+
+ UpdateDb("UPDATE `posts` SET `locked` = %d WHERE `boardid` = '%s' AND `id` = '%s' LIMIT 1" % (setLocked, board["id"], post["id"]))
+ threadUpdated(post['id'])
+ logAction(msg)
+ return renderTemplate('anarkia.html', {'mode': 99, 'msg': msg})
+ elif formdata.get('del'):
+ postid = int(formdata['del'])
+ post = FetchOne('SELECT id, parentid FROM posts WHERE boardid = %s AND id = %d LIMIT 1' % (board['id'], postid))
+ if post['parentid'] != '0':
+ deletePost(post['id'], None, '3', False)
+ msg = "Mensaje %s eliminado." % post['id']
+ logAction(msg)
+ return renderTemplate('anarkia.html', {'mode': 99, 'msg': msg})
+ else:
+ raise UserError, "jaj no"
+ elif formdata.get('restore'):
+ postid = int(formdata['restore'])
+ post = FetchOne('SELECT id, parentid FROM posts WHERE boardid = %s AND id = %d LIMIT 1' % (board['id'], postid))
+
+ UpdateDb('UPDATE `posts` SET `IS_DELETED` = 0 WHERE `boardid` = %s AND `id` = %s LIMIT 1' % (board['id'], post['id']))
+ if post['parentid'] != '0':
+ threadUpdated(post['parentid'])
+ else:
+ regenerateFrontPages()
+ msg = "Mensaje %s recuperado." % post['id']
+ logAction(msg)
+ return renderTemplate('anarkia.html', {'mode': 99, 'msg': msg})
+ elif formdata.get('ban'):
+ postid = int(formdata['ban'])
+ post = FetchOne('SELECT id, ip FROM posts WHERE boardid = %s AND id = %d LIMIT 1' % (board['id'], postid))
+
+ return renderTemplate('anarkia.html', {'mode': 4, 'post': post})
+ elif formdata.get('banto'):
+ postid = int(formdata['banto'])
+ post = FetchOne('SELECT id, message, parentid, ip FROM posts WHERE boardid = %s AND id = %d LIMIT 1' % (board['id'], postid))
+
+ reason = formdata.get('reason').replace('script', '').replace('meta', '')
+ if reason is not None:
+ if formdata['seconds'] != '0':
+ until = str(timestamp() + int(formdata['seconds']))
+ else:
+ until = '0'
+ where = pickle.dumps(['anarkia'])
+
+ ban = FetchOne("SELECT `id` FROM `bans` WHERE `ip` = '" + post['ip'] + "' AND `boards` = '" + _mysql.escape_string(where) + "' LIMIT 1")
+ if ban:
+ raise UserError, "Este usuario ya esta baneado."
+
+ # Blind mode
+ if formdata.get('blind') == '1':
+ blind = '1'
+ else:
+ blind = '0'
+
+ InsertDb("INSERT INTO `bans` (`ip`, `netmask`, `boards`, `added`, `until`, `staff`, `reason`, `blind`) VALUES ('" + post['ip'] + "', INET_ATON('255.255.255.255'), '" + _mysql.escape_string(where) + "', " + str(timestamp()) + ", " + until + ", 'anarko', '" + _mysql.escape_string(formdata['reason']) + "', '"+blind+"')")
+
+ newmessage = post['message'] + '<hr /><span class="banned">A este usuario se le revocó el acceso. Razón: %s</span>' % reason
+
+ UpdateDb("UPDATE posts SET message = '%s' WHERE boardid = %s AND id = %s" % (_mysql.escape_string(newmessage), board['id'], post['id']))
+ if post['parentid'] != '0':
+ threadUpdated(post['parentid'])
+ else:
+ regenerateFrontPages()
+ regenerateAccess()
+
+ msg = "Usuario %s baneado." % post['ip'][:4]
+ logAction(msg)
+ return renderTemplate('anarkia.html', {'mode': 99, 'msg': msg})
+ else:
+ reports = FetchAll("SELECT * FROM `reports` WHERE board = 'anarkia'")
+ threads = FetchAll('SELECT * FROM `posts` WHERE boardid = %s AND parentid = 0 ORDER BY `bumped` DESC' % board['id'])
+ return renderTemplate('anarkia.html', {'mode': 2, 'threads': threads, 'reports': reports})
+
+def boardoptions(formdata):
+ board = Settings._.BOARD
+
+ if formdata.get('longname'):
+ # submitted
+ board['longname'] = formdata['longname'].replace('script', '')
+ board['postarea_desc'] = formdata['postarea_desc'].replace('script', '').replace('meta', '')
+ board['postarea_extra'] = formdata['postarea_extra'].replace('script', '').replace('meta', '')
+ board['anonymous'] = formdata['anonymous'].replace('script', '')
+ board['subject'] = formdata['subject'].replace('script', '')
+ board['message'] = formdata['message'].replace('script', '')
+ board['useid'] = formdata['useid']
+ if 'disable_name' in formdata.keys():
+ board['disable_name'] = '1'
+ else:
+ board['disable_name'] = '0'
+ if 'disable_subject' in formdata.keys():
+ board['disable_subject'] = '1'
+ else:
+ board['disable_subject'] = '0'
+ if 'allow_noimage' in formdata.keys():
+ board['allow_noimage'] = '1'
+ else:
+ board['allow_noimage'] = '0'
+ if 'allow_images' in formdata.keys():
+ board['allow_images'] = '1'
+ else:
+ board['allow_images'] = '0'
+ if 'allow_image_replies' in formdata.keys():
+ board['allow_image_replies'] = '1'
+ else:
+ board['allow_image_replies'] = '0'
+
+ # Update file types
+ UpdateDb("DELETE FROM `boards_filetypes` WHERE `boardid` = %s" % board['id'])
+ for filetype in filetypelist():
+ if 'filetype'+filetype['ext'] in formdata.keys():
+ UpdateDb("INSERT INTO `boards_filetypes` VALUES (%s, %s)" % (board['id'], filetype['id']))
+
+ try:
+ board['maxsize'] = int(formdata['maxsize'])
+ if board['maxsize'] > 10000:
+ board['maxsize'] = 10000
+ except:
+ raise UserError, _("Max size must be numeric.")
+
+ try:
+ board['thumb_px'] = int(formdata['thumb_px'])
+ if board['thumb_px'] > 500:
+ board['thumb_px'] = 500
+ except:
+ raise UserError, _("Max thumb dimensions must be numeric.")
+
+ try:
+ board['numthreads'] = int(formdata['numthreads'])
+ if board['numthreads'] > 15:
+ board['numthreads'] = 15
+ except:
+ raise UserError, _("Max threads shown must be numeric.")
+
+ try:
+ board['numcont'] = int(formdata['numcont'])
+ if board['numcont'] > 15:
+ board['numcont'] = 15
+ except:
+ raise UserError, _("Max replies shown must be numeric.")
+
+ t = time.time()
+ updateBoardSettings()
+ setBoard('anarkia')
+ regenerateBoard(True)
+ tf = timeTaken(t, time.time())
+
+ msg = 'Opciones cambiadas. %s' % tf
+ logAction(msg)
+ return renderTemplate('anarkia.html', {'mode': 99, 'msg': msg})
+ else:
+ return renderTemplate('anarkia.html', {'mode': 1, 'boardopts': board, 'filetypes': filetypelist(), 'supported_filetypes': board['filetypes_ext']})
+
+def emojis(formdata):
+ board = Settings._.BOARD
+ board_pickle = _mysql.escape_string(pickle.dumps([board['dir']]))
+
+ if formdata.get('new'):
+ import re
+ ext = {'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/png': 'png'}
+
+ if not formdata['name']:
+ raise UserError, 'Ingresa nombre.'
+ if not re.match(r"^[0-9a-zA-Z]+$", formdata['name']):
+ raise UserError, 'Nombre inválido; solo letras/números.'
+
+ name = ":%s:" % formdata['name'][:15]
+ data = formdata['file']
+
+ if not data:
+ raise UserError, 'Ingresa imagen.'
+
+ # check if it exists
+ already = FetchOne("SELECT 1 FROM `filters` WHERE `boards` = '%s' AND `from` = '%s'" % (board_pickle, _mysql.escape_string(name)))
+ if already:
+ raise UserError, 'Este emoji ya existe.'
+
+ # get image information
+ content_type, width, height, size, extra = getImageInfo(data)
+
+ if content_type not in ext.keys():
+ raise UserError, 'Formato inválido.'
+ if width > 500 or height > 500:
+ raise UserError, 'Dimensiones muy altas.'
+ if size > 150000:
+ raise UserError, 'Tamaño muy grande.'
+
+ # create file names
+ thumb_width, thumb_height = getThumbDimensions(width, height, 32)
+
+ file_path = Settings.ROOT_DIR + board["dir"] + "/e/" + formdata['name'][:15] + '.' + ext[content_type]
+ file_url = Settings.BOARDS_URL + board["dir"] + "/e/" + formdata['name'][:15] + '.' + ext[content_type]
+ to_filter = '<img src="%s" width="%d" height="%d" />' % (file_url, thumb_width, thumb_height)
+
+ # start processing image
+ args = [Settings.CONVERT_PATH, "-", "-limit" , "thread", "1", "-resize", "%dx%d" % (thumb_width, thumb_height), "-quality", "80", file_path]
+ p = subprocess.Popen(args, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
+ out = p.communicate(input=data)[0]
+
+ # insert into DB
+ sql = "INSERT INTO `filters` (`boards`, `type`, `action`, `from`, `to`, `staff`, `added`) VALUES ('%s', 0, 1, '%s', '%s', 'Anarko', '%s')" % (board_pickle, _mysql.escape_string(name), _mysql.escape_string(to_filter), timestamp())
+ UpdateDb(sql)
+
+ msg = "Emoji %s agregado." % name
+ logAction(msg)
+ return renderTemplate('anarkia.html', {'mode': 99, 'msg': msg})
+ elif formdata.get('del'):
+ return renderTemplate('anarkia.html', {'mode': 99, 'msg': 'Del.'})
+ else:
+ filters = FetchAll("SELECT * FROM `filters` WHERE `boards` = '%s' ORDER BY `added` DESC" % board_pickle)
+ return renderTemplate('anarkia.html', {'mode': 8, 'emojis': filters})
+
+def filetypelist():
+ filetypes = FetchAll('SELECT * FROM `filetypes` ORDER BY `ext` ASC')
+ return filetypes
+
+def logAction(action):
+ InsertDb("INSERT INTO `logs` (`timestamp`, `staff`, `action`) VALUES (" + str(timestamp()) + ", 'Anarko', '" + _mysql.escape_string(action) + "')") \ No newline at end of file
diff --git a/cgi/api.py b/cgi/api.py
new file mode 100644
index 0000000..8960578
--- /dev/null
+++ b/cgi/api.py
@@ -0,0 +1,392 @@
+# coding=utf-8
+import json
+import _mysql
+import time
+
+from framework import *
+from database import *
+from post import *
+
+def api(self, path_split):
+ if len(path_split) > 2:
+ try:
+ self.output = api_process(self, path_split)
+ except APIError, e:
+ self.output = api_error("error", e.message)
+ except UserError, e:
+ self.output = api_error("failed", e.message)
+ except Exception, e:
+ import sys, traceback
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ detail = ["%s : %s : %s : %s" % (os.path.basename(o[0]),o[1],o[2],o[3]) for o in traceback.extract_tb(exc_traceback)]
+
+ self.output = api_error("exception", str(e), str(type(e)), detail)
+ else:
+ self.output = api_error("error", "No method specified")
+
+def api_process(self, path_split):
+ formdata = self.formdata
+ ip = self.environ["REMOTE_ADDR"]
+ t = time.time()
+ method = path_split[2]
+
+ #bans = ['181.72.116.62']
+ bans = []
+ if ip in bans:
+ raise APIError, "You have been blacklisted."
+
+ #with open('../api_log.txt', 'a') as f:
+ # logstr = "[%s] %s: %s\n" % (formatTimestamp(t), ip, repr(path_split))
+ # f.write(logstr)
+
+ values = {'state': 'success'}
+
+ if method == 'boards':
+ boards = FetchAll('SELECT dir, name, board_type, allow_images, allow_image_replies, maxsize FROM `boards` WHERE `secret`=0 ORDER BY `name` ASC')
+ values['boards'] = boards
+ for board in values['boards']:
+ board['board_type'] = int(board['board_type'])
+ board['allow_images'] = int(board['allow_images'])
+ board['allow_image_replies'] = int(board['allow_image_replies'])
+ board['maxsize'] = int(board['maxsize'])
+
+ elif method == 'last':
+ data_limit = formdata.get('limit')
+ data_since = formdata.get('since')
+
+ limit = 10
+ since = 0
+
+ if data_limit:
+ try:
+ limit = int(data_limit)
+ except ValueError:
+ raise APIError, "Limit must be numeric"
+
+ if data_since:
+ try:
+ since = int(data_since)
+ except ValueError:
+ raise APIError, "Since must be numeric"
+
+ if limit > 50:
+ raise APIError, "Maximum limit is 50"
+
+ sql = "SELECT posts.id, boards.dir, timestamp, timestamp_formatted, posts.name, tripcode, email, posts.subject, posts.message, file, file_size, image_height, image_width, thumb, thumb_width, thumb_height, parentid FROM posts INNER JOIN boards ON boardid = boards.id WHERE timestamp > %d AND IS_DELETED = 0 AND email NOT LIKE '%%sage%%' AND boards.secret = 0 ORDER BY timestamp DESC LIMIT %d" % (since, limit)
+ values['posts'] = FetchAll(sql)
+
+ for post in values['posts']:
+ post['id'] = int(post['id'])
+ post['timestamp'] = int(post['timestamp'])
+ post['parentid'] = int(post['parentid'])
+ post['file_size'] = int(post['file_size'])
+ post['image_width'] = int(post['image_width'])
+ post['image_height'] = int(post['image_height'])
+ post['thumb_width'] = int(post['thumb_width'])
+ post['thumb_height'] = int(post['thumb_height'])
+ post['message'] = post['message'].decode('utf-8', 'replace')
+ elif method == 'lastage':
+ data_limit = formdata.get('limit')
+ data_time = formdata.get('time', 0)
+
+ limit = 30
+
+ if data_limit:
+ try:
+ limit = int(data_limit)
+ except ValueError:
+ raise APIError, "Limit must be numeric"
+
+ if limit > 50:
+ raise APIError, "Maximum limit is 50"
+
+ threads = getLastAge(limit)
+ if threads[0]['bumped'] > int(data_time):
+ values['threads'] = threads
+ else:
+ values['threads'] = []
+ elif method == 'list':
+ data_board = formdata.get('dir')
+ data_offset = formdata.get('offset')
+ data_limit = formdata.get('limit')
+ data_replies = formdata.get('replies')
+ offset = 0
+ limit = 10
+ numreplies = 2
+
+ if not data_board:
+ raise APIError, "Missing parameters"
+
+ if data_limit:
+ try:
+ limit = int(data_limit)
+ except ValueError:
+ raise APIError, "Limit must be numeric"
+
+ if data_offset:
+ try:
+ offset = int(data_offset)
+ except ValueError:
+ raise APIError, "Offset must be numeric"
+
+ if data_replies:
+ try:
+ numreplies = int(data_replies)
+ except ValueError:
+ raise APIError, "Replies must be numeric"
+
+ if data_replies and limit > 30:
+ raise APIError, "Maximum limit is 30"
+
+ board = setBoard(data_board)
+
+ #sql = "SELECT id, timestamp, bumped, timestamp_formatted, name, tripcode, email, subject, message, file, thumb FROM posts WHERE boardid = %s AND parentid = 0 AND IS_DELETED = 0 ORDER BY bumped DESC LIMIT %d" % (board['id'], limit)
+ sql = "SELECT p.id, p.timestamp, p.bumped, p.expires, p.expires_formatted, p.timestamp_formatted, p.name, p.tripcode, p.email, p.subject, p.message, p.file, p.file_size, p.image_width, p.image_height, p.thumb, p.thumb_height, p.thumb_width, p.locked, coalesce(x.count,0) AS total_replies, coalesce(x.files,0) AS total_files FROM `posts` AS p LEFT JOIN (SELECT parentid, count(1) as count, count(nullif(file, '')) as files FROM `posts` WHERE boardid = %(board)s GROUP BY parentid) AS x ON p.id=x.parentid WHERE p.parentid = 0 AND p.boardid = %(board)s AND p.IS_DELETED = 0 ORDER BY `bumped` DESC LIMIT %(limit)d OFFSET %(offset)d" % {'board': board["id"], 'limit': limit, 'offset': offset}
+
+ threads = FetchAll(sql)
+
+ if numreplies:
+ for thread in threads:
+ lastreplies = FetchAll("SELECT id, timestamp, timestamp_formatted, name, tripcode, email, subject, message, file, file_size, image_height, image_width, thumb, thumb_width, thumb_height, IS_DELETED FROM `posts` WHERE parentid = %s AND boardid = %s ORDER BY `timestamp` DESC LIMIT %d" % (thread['id'], board['id'], numreplies))
+ lastreplies = lastreplies[::-1]
+ thread['id'] = int(thread['id'])
+ thread['timestamp'] = int(thread['timestamp'])
+ thread['bumped'] = int(thread['bumped'])
+ thread['expires'] = int(thread['expires'])
+ thread['total_replies'] = int(thread['total_replies'])
+ thread['total_files'] = int(thread['total_files'])
+ thread['file_size'] = int(thread['file_size'])
+ thread['image_width'] = int(thread['image_width'])
+ thread['image_height'] = int(thread['image_height'])
+ thread['thumb_width'] = int(thread['thumb_width'])
+ thread['thumb_height'] = int(thread['thumb_height'])
+ thread['locked'] = int(thread['locked'])
+
+ thread['replies'] = []
+
+ for post in lastreplies:
+ post['IS_DELETED'] = int(post['IS_DELETED'])
+ post['id'] = int(post['id'])
+ post['timestamp'] = int(post['timestamp'])
+
+ if post['IS_DELETED']:
+ empty_post = {'id': post['id'],
+ 'IS_DELETED': post['IS_DELETED'],
+ 'timestamp': post['timestamp'],
+ }
+ thread['replies'].append(empty_post)
+ else:
+ post['file_size'] = int(post['file_size'])
+ post['image_width'] = int(post['image_width'])
+ post['image_height'] = int(post['image_height'])
+ post['thumb_width'] = int(post['thumb_width'])
+ post['thumb_height'] = int(post['thumb_height'])
+ post['message'] = post['message'].decode('utf-8', 'replace')
+
+ thread['replies'].append(post)
+
+ values['threads'] = threads
+ elif method == 'thread':
+ data_board = formdata.get('dir')
+ data_threadid = formdata.get('id')
+ data_threadts = formdata.get('ts')
+ data_offset = formdata.get('offset')
+ data_limit = formdata.get('limit')
+ data_striphtml = formdata.get('nohtml')
+ striphtml = False
+ offset = 0
+ limit = 1000
+
+ if not data_board or (not data_threadid and not data_threadts):
+ raise APIError, "Missing parameters"
+
+ if data_limit:
+ try:
+ limit = int(data_limit)
+ except ValueError:
+ raise APIError, "Limit must be numeric"
+
+ if data_offset:
+ try:
+ offset = int(data_offset)
+ except ValueError:
+ raise APIError, "Offset must be numeric"
+
+ if data_striphtml:
+ if int(data_striphtml) == 1:
+ striphtml = True
+
+ board = setBoard(data_board)
+ search_field = 'id'
+ search_val = 0
+
+ try:
+ search_val = int(data_threadid)
+ except (ValueError, TypeError):
+ pass
+
+ try:
+ search_val = int(data_threadts)
+ search_field = 'timestamp'
+ except (ValueError, TypeError):
+ pass
+
+ if not search_val:
+ raise APIError, "No thread ID"
+
+ op_post = FetchOne("SELECT id, timestamp, subject, locked FROM posts WHERE `%s` = '%d' AND boardid = '%s' AND parentid = 0" % (search_field, search_val, board["id"]))
+
+ if not op_post:
+ raise APIError, "Not a thread"
+
+ values['id'] = int(op_post['id'])
+ values['timestamp'] = int(op_post['timestamp'])
+ values['subject'] = op_post['subject']
+ values['locked'] = int(op_post['locked'])
+
+ total_replies = int(FetchOne("SELECT COUNT(1) FROM posts WHERE boardid = '%s' AND parentid = '%d'" % (board["id"], values['id']), 0)[0])
+
+ values['total_replies'] = total_replies
+
+ sql = "SELECT id, parentid, timestamp, timestamp_formatted, name, tripcode, email, subject, message, file, file_size, image_width, image_height, thumb, thumb_width, thumb_height, IS_DELETED FROM posts WHERE boardid = %s AND (parentid = %s OR id = %s) ORDER BY id ASC LIMIT %d OFFSET %d" % (_mysql.escape_string(board['id']), values['id'], values['id'], limit, offset)
+ posts = FetchAll(sql)
+
+ values['posts'] = []
+
+ for post in posts:
+ post['IS_DELETED'] = int(post['IS_DELETED'])
+ post['id'] = int(post['id'])
+ post['parentid'] = int(post['parentid'])
+ post['timestamp'] = int(post['timestamp'])
+
+ if post['IS_DELETED']:
+ empty_post = {'id': post['id'],
+ 'IS_DELETED': post['IS_DELETED'],
+ 'parentid': post['parentid'],
+ 'timestamp': post['timestamp'],
+ }
+ values['posts'].append(empty_post)
+ else:
+ post['file_size'] = int(post['file_size'])
+ post['image_width'] = int(post['image_width'])
+ post['image_height'] = int(post['image_height'])
+ post['thumb_width'] = int(post['thumb_width'])
+ post['thumb_height'] = int(post['thumb_height'])
+ post['message'] = post['message'].decode('utf-8', 'replace')
+ if striphtml:
+ post['message'] = post['message'].replace("<br />", " ")
+ post['message'] = re.compile(r"<[^>]*?>", re.DOTALL | re.IGNORECASE).sub("", post['message'])
+ values['posts'].append(post)
+ elif method == 'get':
+ data_board = formdata.get('dir')
+ data_parentid = formdata.get('thread')
+ data_postid = formdata.get('id')
+ data_postnum = formdata.get('num')
+
+ if not data_board and (not data_postid or (not data_postnum and not data_parentid)):
+ raise APIError, "Missing parameters"
+
+ board = setBoard(data_board)
+ postid = 0
+
+ if data_postnum:
+ data_postid = getID(data_parentid, data_postid)
+
+ try:
+ postid = int(data_postid)
+ except ValueError:
+ raise APIError, "Post ID must be numeric"
+
+ post = FetchOne("SELECT id, parentid, timestamp, timestamp_formatted, name, tripcode, email, subject, message, file, file_size, image_width, image_height, thumb, thumb_width, thumb_height, IS_DELETED FROM posts WHERE `id`='%d' AND boardid='%s'" % (postid, board["id"]))
+
+ if not post:
+ raise APIError, "Post ID cannot be found"
+
+ values['posts'] = []
+
+ post['IS_DELETED'] = int(post['IS_DELETED'])
+ post['id'] = int(post['id'])
+ post['parentid'] = int(post['parentid'])
+ post['timestamp'] = int(post['timestamp'])
+
+ if post['IS_DELETED']:
+ empty_post = {'id': post['id'],
+ 'IS_DELETED': post['IS_DELETED'],
+ 'parentid': post['parentid'],
+ 'timestamp': post['timestamp'],
+ }
+ values['posts'].append(empty_post)
+ else:
+ post['file_size'] = int(post['file_size'])
+ post['image_width'] = int(post['image_width'])
+ post['image_height'] = int(post['image_height'])
+ post['thumb_width'] = int(post['thumb_width'])
+ post['thumb_height'] = int(post['thumb_height'])
+ post['message'] = post['message'].decode('utf-8', 'replace')
+ values['posts'].append(post)
+ elif method == 'delete':
+ data_board = formdata.get('dir')
+ data_postid = formdata.get('id')
+ data_imageonly = formdata.get('imageonly')
+ data_password = formdata.get('password')
+
+ if not data_board or not data_postid or not data_password:
+ raise APIError, "Missing parameters"
+
+ imageonly = False
+ board = setBoard(data_board)
+
+ try:
+ postid = int(data_postid)
+ except ValueError:
+ raise APIError, "Post ID must be numeric"
+
+ if data_imageonly and data_imageonly == 1:
+ imageonly = True
+
+ deletePost(postid, data_password, board['recyclebin'], imageonly)
+ elif method == 'post':
+ boarddir = formdata.get('board')
+
+ if not boarddir:
+ raise APIError, "Missing parameters"
+
+ parent = formdata.get('parent')
+ trap1 = formdata.get('name', '')
+ trap2 = formdata.get('email', '')
+ name = formdata.get('fielda', '')
+ email = formdata.get('fieldb', '')
+ subject = formdata.get('subject', '')
+ message = formdata.get('message', '')
+ file = formdata.get('file')
+ file_original = formdata.get('file_original')
+ spoil = formdata.get('spoil')
+ oek_file = formdata.get('oek_file')
+ password = formdata.get('password', '')
+ noimage = formdata.get('noimage')
+ mobile = ("mobile" in formdata.keys())
+
+ # call post function
+ (post_url, ttaken) = self.make_post(ip, boarddir, parent, trap1, trap2, name, email, subject, message, file, file_original, spoil, oek_file, password, noimage, mobile)
+
+ values['post_url'] = post_url
+ values['time_taken'] = ttaken
+ else:
+ raise APIError, "Invalid method"
+
+ values['time'] = int(t)
+ #values['time_taken'] = time.time() - t
+ return json.dumps(values, sort_keys=True, separators=(',',':'))
+
+def api_error(errtype, msg, type=None, detail=None):
+ values = {'state': errtype, 'message': msg}
+
+ if type:
+ values['type'] = type
+ if detail:
+ values['detail'] = detail
+
+ return json.dumps(values)
+
+class APIError(Exception):
+ pass
diff --git a/cgi/database.py b/cgi/database.py
new file mode 100644
index 0000000..c8611c5
--- /dev/null
+++ b/cgi/database.py
@@ -0,0 +1,69 @@
+# coding=utf-8
+
+import threading
+import _mysql
+from settings import Settings
+
+database_lock = threading.Lock()
+
+try:
+ # Although SQLAlchemy is optional, it is highly recommended
+ import sqlalchemy.pool as pool
+ _mysql = pool.manage( module = _mysql,
+ pool_size = Settings.DATABASE_POOL_SIZE,
+ max_overflow = Settings.DATABASE_POOL_OVERFLOW)
+ Settings._.USING_SQLALCHEMY = True
+except ImportError:
+ pass
+
+def OpenDb():
+ if Settings._.CONN is None:
+ Settings._.CONN = _mysql.connect(host = Settings.DATABASE_HOST,
+ user = Settings.DATABASE_USERNAME,
+ passwd = Settings.DATABASE_PASSWORD,
+ db = Settings.DATABASE_DB)
+
+def FetchAll(query, method=1):
+ """
+ Query and fetch all results as a list
+ """
+ db = Settings._.CONN
+
+ db.query(query)
+ r = db.use_result()
+ return r.fetch_row(0, method)
+
+def FetchOne(query, method=1):
+ """
+ Query and fetch only the first result
+ """
+ db = Settings._.CONN
+
+ db.query(query)
+ r = db.use_result()
+ try:
+ return r.fetch_row(1, method)[0]
+ except:
+ return None
+
+def UpdateDb(query):
+ """
+ Update the DB (UPDATE/DELETE) and return # of affected rows
+ """
+ db = Settings._.CONN
+
+ db.query(query)
+ return db.affected_rows()
+
+def InsertDb(query):
+ """
+ Insert into the DB and return the primary key of new row
+ """
+ db = Settings._.CONN
+
+ db.query(query)
+ return db.insert_id()
+
+def CloseDb():
+ if Settings._.CONN is not None:
+ Settings._.CONN.close()
diff --git a/cgi/fcgi.py b/cgi/fcgi.py
new file mode 100644
index 0000000..8677679
--- /dev/null
+++ b/cgi/fcgi.py
@@ -0,0 +1,1332 @@
+# Copyright (c) 2002, 2003, 2005, 2006 Allan Saddi <allan@saddi.com>
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1. Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# 2. Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
+# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+# SUCH DAMAGE.
+#
+# $Id$
+
+"""
+fcgi - a FastCGI/WSGI gateway.
+
+For more information about FastCGI, see <http://www.fastcgi.com/>.
+
+For more information about the Web Server Gateway Interface, see
+<http://www.python.org/peps/pep-0333.html>.
+
+Example usage:
+
+ #!/usr/bin/env python
+ from myapplication import app # Assume app is your WSGI application object
+ from fcgi import WSGIServer
+ WSGIServer(app).run()
+
+See the documentation for WSGIServer/Server for more information.
+
+On most platforms, fcgi will fallback to regular CGI behavior if run in a
+non-FastCGI context. If you want to force CGI behavior, set the environment
+variable FCGI_FORCE_CGI to "Y" or "y".
+"""
+
+__author__ = 'Allan Saddi <allan@saddi.com>'
+__version__ = '$Revision$'
+
+import sys
+import os
+import signal
+import struct
+import cStringIO as StringIO
+import select
+import socket
+import errno
+import traceback
+
+try:
+ import thread
+ import threading
+ thread_available = True
+except ImportError:
+ import dummy_thread as thread
+ import dummy_threading as threading
+ thread_available = False
+
+# Apparently 2.3 doesn't define SHUT_WR? Assume it is 1 in this case.
+if not hasattr(socket, 'SHUT_WR'):
+ socket.SHUT_WR = 1
+
+__all__ = ['WSGIServer']
+
+# Constants from the spec.
+FCGI_LISTENSOCK_FILENO = 0
+
+FCGI_HEADER_LEN = 8
+
+FCGI_VERSION_1 = 1
+
+FCGI_BEGIN_REQUEST = 1
+FCGI_ABORT_REQUEST = 2
+FCGI_END_REQUEST = 3
+FCGI_PARAMS = 4
+FCGI_STDIN = 5
+FCGI_STDOUT = 6
+FCGI_STDERR = 7
+FCGI_DATA = 8
+FCGI_GET_VALUES = 9
+FCGI_GET_VALUES_RESULT = 10
+FCGI_UNKNOWN_TYPE = 11
+FCGI_MAXTYPE = FCGI_UNKNOWN_TYPE
+
+FCGI_NULL_REQUEST_ID = 0
+
+FCGI_KEEP_CONN = 1
+
+FCGI_RESPONDER = 1
+FCGI_AUTHORIZER = 2
+FCGI_FILTER = 3
+
+FCGI_REQUEST_COMPLETE = 0
+FCGI_CANT_MPX_CONN = 1
+FCGI_OVERLOADED = 2
+FCGI_UNKNOWN_ROLE = 3
+
+FCGI_MAX_CONNS = 'FCGI_MAX_CONNS'
+FCGI_MAX_REQS = 'FCGI_MAX_REQS'
+FCGI_MPXS_CONNS = 'FCGI_MPXS_CONNS'
+
+FCGI_Header = '!BBHHBx'
+FCGI_BeginRequestBody = '!HB5x'
+FCGI_EndRequestBody = '!LB3x'
+FCGI_UnknownTypeBody = '!B7x'
+
+FCGI_EndRequestBody_LEN = struct.calcsize(FCGI_EndRequestBody)
+FCGI_UnknownTypeBody_LEN = struct.calcsize(FCGI_UnknownTypeBody)
+
+if __debug__:
+ import time
+
+ # Set non-zero to write debug output to a file.
+ DEBUG = 0
+ DEBUGLOG = '/tmp/fcgi.log'
+
+ def _debug(level, msg):
+ if DEBUG < level:
+ return
+
+ try:
+ f = open(DEBUGLOG, 'a')
+ f.write('%sfcgi: %s\n' % (time.ctime()[4:-4], msg))
+ f.close()
+ except:
+ pass
+
+class InputStream(object):
+ """
+ File-like object representing FastCGI input streams (FCGI_STDIN and
+ FCGI_DATA). Supports the minimum methods required by WSGI spec.
+ """
+ def __init__(self, conn):
+ self._conn = conn
+
+ # See Server.
+ self._shrinkThreshold = conn.server.inputStreamShrinkThreshold
+
+ self._buf = ''
+ self._bufList = []
+ self._pos = 0 # Current read position.
+ self._avail = 0 # Number of bytes currently available.
+
+ self._eof = False # True when server has sent EOF notification.
+
+ def _shrinkBuffer(self):
+ """Gets rid of already read data (since we can't rewind)."""
+ if self._pos >= self._shrinkThreshold:
+ self._buf = self._buf[self._pos:]
+ self._avail -= self._pos
+ self._pos = 0
+
+ assert self._avail >= 0
+
+ def _waitForData(self):
+ """Waits for more data to become available."""
+ self._conn.process_input()
+
+ def read(self, n=-1):
+ if self._pos == self._avail and self._eof:
+ return ''
+ while True:
+ if n < 0 or (self._avail - self._pos) < n:
+ # Not enough data available.
+ if self._eof:
+ # And there's no more coming.
+ newPos = self._avail
+ break
+ else:
+ # Wait for more data.
+ self._waitForData()
+ continue
+ else:
+ newPos = self._pos + n
+ break
+ # Merge buffer list, if necessary.
+ if self._bufList:
+ self._buf += ''.join(self._bufList)
+ self._bufList = []
+ r = self._buf[self._pos:newPos]
+ self._pos = newPos
+ self._shrinkBuffer()
+ return r
+
+ def readline(self, length=None):
+ if self._pos == self._avail and self._eof:
+ return ''
+ while True:
+ # Unfortunately, we need to merge the buffer list early.
+ if self._bufList:
+ self._buf += ''.join(self._bufList)
+ self._bufList = []
+ # Find newline.
+ i = self._buf.find('\n', self._pos)
+ if i < 0:
+ # Not found?
+ if self._eof:
+ # No more data coming.
+ newPos = self._avail
+ break
+ else:
+ # Wait for more to come.
+ self._waitForData()
+ continue
+ else:
+ newPos = i + 1
+ break
+ if length is not None:
+ if self._pos + length < newPos:
+ newPos = self._pos + length
+ r = self._buf[self._pos:newPos]
+ self._pos = newPos
+ self._shrinkBuffer()
+ return r
+
+ def readlines(self, sizehint=0):
+ total = 0
+ lines = []
+ line = self.readline()
+ while line:
+ lines.append(line)
+ total += len(line)
+ if 0 < sizehint <= total:
+ break
+ line = self.readline()
+ return lines
+
+ def __iter__(self):
+ return self
+
+ def next(self):
+ r = self.readline()
+ if not r:
+ raise StopIteration
+ return r
+
+ def add_data(self, data):
+ if not data:
+ self._eof = True
+ else:
+ self._bufList.append(data)
+ self._avail += len(data)
+
+class MultiplexedInputStream(InputStream):
+ """
+ A version of InputStream meant to be used with MultiplexedConnections.
+ Assumes the MultiplexedConnection (the producer) and the Request
+ (the consumer) are running in different threads.
+ """
+ def __init__(self, conn):
+ super(MultiplexedInputStream, self).__init__(conn)
+
+ # Arbitrates access to this InputStream (it's used simultaneously
+ # by a Request and its owning Connection object).
+ lock = threading.RLock()
+
+ # Notifies Request thread that there is new data available.
+ self._lock = threading.Condition(lock)
+
+ def _waitForData(self):
+ # Wait for notification from add_data().
+ self._lock.wait()
+
+ def read(self, n=-1):
+ self._lock.acquire()
+ try:
+ return super(MultiplexedInputStream, self).read(n)
+ finally:
+ self._lock.release()
+
+ def readline(self, length=None):
+ self._lock.acquire()
+ try:
+ return super(MultiplexedInputStream, self).readline(length)
+ finally:
+ self._lock.release()
+
+ def add_data(self, data):
+ self._lock.acquire()
+ try:
+ super(MultiplexedInputStream, self).add_data(data)
+ self._lock.notify()
+ finally:
+ self._lock.release()
+
+class OutputStream(object):
+ """
+ FastCGI output stream (FCGI_STDOUT/FCGI_STDERR). By default, calls to
+ write() or writelines() immediately result in Records being sent back
+ to the server. Buffering should be done in a higher level!
+ """
+ def __init__(self, conn, req, type, buffered=False):
+ self._conn = conn
+ self._req = req
+ self._type = type
+ self._buffered = buffered
+ self._bufList = [] # Used if buffered is True
+ self.dataWritten = False
+ self.closed = False
+
+ def _write(self, data):
+ length = len(data)
+ while length:
+ toWrite = min(length, self._req.server.maxwrite - FCGI_HEADER_LEN)
+
+ rec = Record(self._type, self._req.requestId)
+ rec.contentLength = toWrite
+ rec.contentData = data[:toWrite]
+ self._conn.writeRecord(rec)
+
+ data = data[toWrite:]
+ length -= toWrite
+
+ def write(self, data):
+ assert not self.closed
+
+ if not data:
+ return
+
+ self.dataWritten = True
+
+ if self._buffered:
+ self._bufList.append(data)
+ else:
+ self._write(data)
+
+ def writelines(self, lines):
+ assert not self.closed
+
+ for line in lines:
+ self.write(line)
+
+ def flush(self):
+ # Only need to flush if this OutputStream is actually buffered.
+ if self._buffered:
+ data = ''.join(self._bufList)
+ self._bufList = []
+ self._write(data)
+
+ # Though available, the following should NOT be called by WSGI apps.
+ def close(self):
+ """Sends end-of-stream notification, if necessary."""
+ if not self.closed and self.dataWritten:
+ self.flush()
+ rec = Record(self._type, self._req.requestId)
+ self._conn.writeRecord(rec)
+ self.closed = True
+
+class TeeOutputStream(object):
+ """
+ Simple wrapper around two or more output file-like objects that copies
+ written data to all streams.
+ """
+ def __init__(self, streamList):
+ self._streamList = streamList
+
+ def write(self, data):
+ for f in self._streamList:
+ f.write(data)
+
+ def writelines(self, lines):
+ for line in lines:
+ self.write(line)
+
+ def flush(self):
+ for f in self._streamList:
+ f.flush()
+
+class StdoutWrapper(object):
+ """
+ Wrapper for sys.stdout so we know if data has actually been written.
+ """
+ def __init__(self, stdout):
+ self._file = stdout
+ self.dataWritten = False
+
+ def write(self, data):
+ if data:
+ self.dataWritten = True
+ self._file.write(data)
+
+ def writelines(self, lines):
+ for line in lines:
+ self.write(line)
+
+ def __getattr__(self, name):
+ return getattr(self._file, name)
+
+def decode_pair(s, pos=0):
+ """
+ Decodes a name/value pair.
+
+ The number of bytes decoded as well as the name/value pair
+ are returned.
+ """
+ nameLength = ord(s[pos])
+ if nameLength & 128:
+ nameLength = struct.unpack('!L', s[pos:pos+4])[0] & 0x7fffffff
+ pos += 4
+ else:
+ pos += 1
+
+ valueLength = ord(s[pos])
+ if valueLength & 128:
+ valueLength = struct.unpack('!L', s[pos:pos+4])[0] & 0x7fffffff
+ pos += 4
+ else:
+ pos += 1
+
+ name = s[pos:pos+nameLength]
+ pos += nameLength
+ value = s[pos:pos+valueLength]
+ pos += valueLength
+
+ return (pos, (name, value))
+
+def encode_pair(name, value):
+ """
+ Encodes a name/value pair.
+
+ The encoded string is returned.
+ """
+ nameLength = len(name)
+ if nameLength < 128:
+ s = chr(nameLength)
+ else:
+ s = struct.pack('!L', nameLength | 0x80000000L)
+
+ valueLength = len(value)
+ if valueLength < 128:
+ s += chr(valueLength)
+ else:
+ s += struct.pack('!L', valueLength | 0x80000000L)
+
+ return s + name + value
+
+class Record(object):
+ """
+ A FastCGI Record.
+
+ Used for encoding/decoding records.
+ """
+ def __init__(self, type=FCGI_UNKNOWN_TYPE, requestId=FCGI_NULL_REQUEST_ID):
+ self.version = FCGI_VERSION_1
+ self.type = type
+ self.requestId = requestId
+ self.contentLength = 0
+ self.paddingLength = 0
+ self.contentData = ''
+
+ def _recvall(sock, length):
+ """
+ Attempts to receive length bytes from a socket, blocking if necessary.
+ (Socket may be blocking or non-blocking.)
+ """
+ dataList = []
+ recvLen = 0
+ while length:
+ try:
+ data = sock.recv(length)
+ except socket.error, e:
+ if e[0] == errno.EAGAIN:
+ select.select([sock], [], [])
+ continue
+ else:
+ raise
+ if not data: # EOF
+ break
+ dataList.append(data)
+ dataLen = len(data)
+ recvLen += dataLen
+ length -= dataLen
+ return ''.join(dataList), recvLen
+ _recvall = staticmethod(_recvall)
+
+ def read(self, sock):
+ """Read and decode a Record from a socket."""
+ try:
+ header, length = self._recvall(sock, FCGI_HEADER_LEN)
+ except:
+ raise EOFError
+
+ if length < FCGI_HEADER_LEN:
+ raise EOFError
+
+ self.version, self.type, self.requestId, self.contentLength, \
+ self.paddingLength = struct.unpack(FCGI_Header, header)
+
+ if __debug__: _debug(9, 'read: fd = %d, type = %d, requestId = %d, '
+ 'contentLength = %d' %
+ (sock.fileno(), self.type, self.requestId,
+ self.contentLength))
+
+ if self.contentLength:
+ try:
+ self.contentData, length = self._recvall(sock,
+ self.contentLength)
+ except:
+ raise EOFError
+
+ if length < self.contentLength:
+ raise EOFError
+
+ if self.paddingLength:
+ try:
+ self._recvall(sock, self.paddingLength)
+ except:
+ raise EOFError
+
+ def _sendall(sock, data):
+ """
+ Writes data to a socket and does not return until all the data is sent.
+ """
+ length = len(data)
+ while length:
+ try:
+ sent = sock.send(data)
+ except socket.error, e:
+ if e[0] == errno.EAGAIN:
+ select.select([], [sock], [])
+ continue
+ else:
+ raise
+ data = data[sent:]
+ length -= sent
+ _sendall = staticmethod(_sendall)
+
+ def write(self, sock):
+ """Encode and write a Record to a socket."""
+ self.paddingLength = -self.contentLength & 7
+
+ if __debug__: _debug(9, 'write: fd = %d, type = %d, requestId = %d, '
+ 'contentLength = %d' %
+ (sock.fileno(), self.type, self.requestId,
+ self.contentLength))
+
+ header = struct.pack(FCGI_Header, self.version, self.type,
+ self.requestId, self.contentLength,
+ self.paddingLength)
+ self._sendall(sock, header)
+ if self.contentLength:
+ self._sendall(sock, self.contentData)
+ if self.paddingLength:
+ self._sendall(sock, '\x00'*self.paddingLength)
+
+class Request(object):
+ """
+ Represents a single FastCGI request.
+
+ These objects are passed to your handler and is the main interface
+ between your handler and the fcgi module. The methods should not
+ be called by your handler. However, server, params, stdin, stdout,
+ stderr, and data are free for your handler's use.
+ """
+ def __init__(self, conn, inputStreamClass):
+ self._conn = conn
+
+ self.server = conn.server
+ self.params = {}
+ self.stdin = inputStreamClass(conn)
+ self.stdout = OutputStream(conn, self, FCGI_STDOUT)
+ self.stderr = OutputStream(conn, self, FCGI_STDERR, buffered=True)
+ self.data = inputStreamClass(conn)
+
+ def run(self):
+ """Runs the handler, flushes the streams, and ends the request."""
+ try:
+ protocolStatus, appStatus = self.server.handler(self)
+ except:
+ traceback.print_exc(file=self.stderr)
+ self.stderr.flush()
+ if not self.stdout.dataWritten:
+ self.server.error(self)
+
+ protocolStatus, appStatus = FCGI_REQUEST_COMPLETE, 0
+
+ if __debug__: _debug(1, 'protocolStatus = %d, appStatus = %d' %
+ (protocolStatus, appStatus))
+
+ self._flush()
+ self._end(appStatus, protocolStatus)
+
+ def _end(self, appStatus=0L, protocolStatus=FCGI_REQUEST_COMPLETE):
+ self._conn.end_request(self, appStatus, protocolStatus)
+
+ def _flush(self):
+ self.stdout.close()
+ self.stderr.close()
+
+class CGIRequest(Request):
+ """A normal CGI request disguised as a FastCGI request."""
+ def __init__(self, server):
+ # These are normally filled in by Connection.
+ self.requestId = 1
+ self.role = FCGI_RESPONDER
+ self.flags = 0
+ self.aborted = False
+
+ self.server = server
+ self.params = dict(os.environ)
+ self.stdin = sys.stdin
+ self.stdout = StdoutWrapper(sys.stdout) # Oh, the humanity!
+ self.stderr = sys.stderr
+ self.data = StringIO.StringIO()
+
+ def _end(self, appStatus=0L, protocolStatus=FCGI_REQUEST_COMPLETE):
+ sys.exit(appStatus)
+
+ def _flush(self):
+ # Not buffered, do nothing.
+ pass
+
+class Connection(object):
+ """
+ A Connection with the web server.
+
+ Each Connection is associated with a single socket (which is
+ connected to the web server) and is responsible for handling all
+ the FastCGI message processing for that socket.
+ """
+ _multiplexed = False
+ _inputStreamClass = InputStream
+
+ def __init__(self, sock, addr, server):
+ self._sock = sock
+ self._addr = addr
+ self.server = server
+
+ # Active Requests for this Connection, mapped by request ID.
+ self._requests = {}
+
+ def _cleanupSocket(self):
+ """Close the Connection's socket."""
+ try:
+ self._sock.shutdown(socket.SHUT_WR)
+ except:
+ return
+ try:
+ while True:
+ r, w, e = select.select([self._sock], [], [])
+ if not r or not self._sock.recv(1024):
+ break
+ except:
+ pass
+ self._sock.close()
+
+ def run(self):
+ """Begin processing data from the socket."""
+ self._keepGoing = True
+ while self._keepGoing:
+ try:
+ self.process_input()
+ except EOFError:
+ break
+ except (select.error, socket.error), e:
+ if e[0] == errno.EBADF: # Socket was closed by Request.
+ break
+ raise
+
+ self._cleanupSocket()
+
+ def process_input(self):
+ """Attempt to read a single Record from the socket and process it."""
+ # Currently, any children Request threads notify this Connection
+ # that it is no longer needed by closing the Connection's socket.
+ # We need to put a timeout on select, otherwise we might get
+ # stuck in it indefinitely... (I don't like this solution.)
+ while self._keepGoing:
+ try:
+ r, w, e = select.select([self._sock], [], [], 1.0)
+ except ValueError:
+ # Sigh. ValueError gets thrown sometimes when passing select
+ # a closed socket.
+ raise EOFError
+ if r: break
+ if not self._keepGoing:
+ return
+ rec = Record()
+ rec.read(self._sock)
+
+ if rec.type == FCGI_GET_VALUES:
+ self._do_get_values(rec)
+ elif rec.type == FCGI_BEGIN_REQUEST:
+ self._do_begin_request(rec)
+ elif rec.type == FCGI_ABORT_REQUEST:
+ self._do_abort_request(rec)
+ elif rec.type == FCGI_PARAMS:
+ self._do_params(rec)
+ elif rec.type == FCGI_STDIN:
+ self._do_stdin(rec)
+ elif rec.type == FCGI_DATA:
+ self._do_data(rec)
+ elif rec.requestId == FCGI_NULL_REQUEST_ID:
+ self._do_unknown_type(rec)
+ else:
+ # Need to complain about this.
+ pass
+
+ def writeRecord(self, rec):
+ """
+ Write a Record to the socket.
+ """
+ rec.write(self._sock)
+
+ def end_request(self, req, appStatus=0L,
+ protocolStatus=FCGI_REQUEST_COMPLETE, remove=True):
+ """
+ End a Request.
+
+ Called by Request objects. An FCGI_END_REQUEST Record is
+ sent to the web server. If the web server no longer requires
+ the connection, the socket is closed, thereby ending this
+ Connection (run() returns).
+ """
+ rec = Record(FCGI_END_REQUEST, req.requestId)
+ rec.contentData = struct.pack(FCGI_EndRequestBody, appStatus,
+ protocolStatus)
+ rec.contentLength = FCGI_EndRequestBody_LEN
+ self.writeRecord(rec)
+
+ if remove:
+ del self._requests[req.requestId]
+
+ if __debug__: _debug(2, 'end_request: flags = %d' % req.flags)
+
+ if not (req.flags & FCGI_KEEP_CONN) and not self._requests:
+ self._cleanupSocket()
+ self._keepGoing = False
+
+ def _do_get_values(self, inrec):
+ """Handle an FCGI_GET_VALUES request from the web server."""
+ outrec = Record(FCGI_GET_VALUES_RESULT)
+
+ pos = 0
+ while pos < inrec.contentLength:
+ pos, (name, value) = decode_pair(inrec.contentData, pos)
+ cap = self.server.capability.get(name)
+ if cap is not None:
+ outrec.contentData += encode_pair(name, str(cap))
+
+ outrec.contentLength = len(outrec.contentData)
+ self.writeRecord(outrec)
+
+ def _do_begin_request(self, inrec):
+ """Handle an FCGI_BEGIN_REQUEST from the web server."""
+ role, flags = struct.unpack(FCGI_BeginRequestBody, inrec.contentData)
+
+ req = self.server.request_class(self, self._inputStreamClass)
+ req.requestId, req.role, req.flags = inrec.requestId, role, flags
+ req.aborted = False
+
+ if not self._multiplexed and self._requests:
+ # Can't multiplex requests.
+ self.end_request(req, 0L, FCGI_CANT_MPX_CONN, remove=False)
+ else:
+ self._requests[inrec.requestId] = req
+
+ def _do_abort_request(self, inrec):
+ """
+ Handle an FCGI_ABORT_REQUEST from the web server.
+
+ We just mark a flag in the associated Request.
+ """
+ req = self._requests.get(inrec.requestId)
+ if req is not None:
+ req.aborted = True
+
+ def _start_request(self, req):
+ """Run the request."""
+ # Not multiplexed, so run it inline.
+ req.run()
+
+ def _do_params(self, inrec):
+ """
+ Handle an FCGI_PARAMS Record.
+
+ If the last FCGI_PARAMS Record is received, start the request.
+ """
+ req = self._requests.get(inrec.requestId)
+ if req is not None:
+ if inrec.contentLength:
+ pos = 0
+ while pos < inrec.contentLength:
+ pos, (name, value) = decode_pair(inrec.contentData, pos)
+ req.params[name] = value
+ else:
+ self._start_request(req)
+
+ def _do_stdin(self, inrec):
+ """Handle the FCGI_STDIN stream."""
+ req = self._requests.get(inrec.requestId)
+ if req is not None:
+ req.stdin.add_data(inrec.contentData)
+
+ def _do_data(self, inrec):
+ """Handle the FCGI_DATA stream."""
+ req = self._requests.get(inrec.requestId)
+ if req is not None:
+ req.data.add_data(inrec.contentData)
+
+ def _do_unknown_type(self, inrec):
+ """Handle an unknown request type. Respond accordingly."""
+ outrec = Record(FCGI_UNKNOWN_TYPE)
+ outrec.contentData = struct.pack(FCGI_UnknownTypeBody, inrec.type)
+ outrec.contentLength = FCGI_UnknownTypeBody_LEN
+ self.writeRecord(rec)
+
+class MultiplexedConnection(Connection):
+ """
+ A version of Connection capable of handling multiple requests
+ simultaneously.
+ """
+ _multiplexed = True
+ _inputStreamClass = MultiplexedInputStream
+
+ def __init__(self, sock, addr, server):
+ super(MultiplexedConnection, self).__init__(sock, addr, server)
+
+ # Used to arbitrate access to self._requests.
+ lock = threading.RLock()
+
+ # Notification is posted everytime a request completes, allowing us
+ # to quit cleanly.
+ self._lock = threading.Condition(lock)
+
+ def _cleanupSocket(self):
+ # Wait for any outstanding requests before closing the socket.
+ self._lock.acquire()
+ while self._requests:
+ self._lock.wait()
+ self._lock.release()
+
+ super(MultiplexedConnection, self)._cleanupSocket()
+
+ def writeRecord(self, rec):
+ # Must use locking to prevent intermingling of Records from different
+ # threads.
+ self._lock.acquire()
+ try:
+ # Probably faster than calling super. ;)
+ rec.write(self._sock)
+ finally:
+ self._lock.release()
+
+ def end_request(self, req, appStatus=0L,
+ protocolStatus=FCGI_REQUEST_COMPLETE, remove=True):
+ self._lock.acquire()
+ try:
+ super(MultiplexedConnection, self).end_request(req, appStatus,
+ protocolStatus,
+ remove)
+ self._lock.notify()
+ finally:
+ self._lock.release()
+
+ def _do_begin_request(self, inrec):
+ self._lock.acquire()
+ try:
+ super(MultiplexedConnection, self)._do_begin_request(inrec)
+ finally:
+ self._lock.release()
+
+ def _do_abort_request(self, inrec):
+ self._lock.acquire()
+ try:
+ super(MultiplexedConnection, self)._do_abort_request(inrec)
+ finally:
+ self._lock.release()
+
+ def _start_request(self, req):
+ thread.start_new_thread(req.run, ())
+
+ def _do_params(self, inrec):
+ self._lock.acquire()
+ try:
+ super(MultiplexedConnection, self)._do_params(inrec)
+ finally:
+ self._lock.release()
+
+ def _do_stdin(self, inrec):
+ self._lock.acquire()
+ try:
+ super(MultiplexedConnection, self)._do_stdin(inrec)
+ finally:
+ self._lock.release()
+
+ def _do_data(self, inrec):
+ self._lock.acquire()
+ try:
+ super(MultiplexedConnection, self)._do_data(inrec)
+ finally:
+ self._lock.release()
+
+class Server(object):
+ """
+ The FastCGI server.
+
+ Waits for connections from the web server, processing each
+ request.
+
+ If run in a normal CGI context, it will instead instantiate a
+ CGIRequest and run the handler through there.
+ """
+ request_class = Request
+ cgirequest_class = CGIRequest
+
+ # Limits the size of the InputStream's string buffer to this size + the
+ # server's maximum Record size. Since the InputStream is not seekable,
+ # we throw away already-read data once this certain amount has been read.
+ inputStreamShrinkThreshold = 102400 - 8192
+
+ def __init__(self, handler=None, maxwrite=8192, bindAddress=None,
+ umask=None, multiplexed=False):
+ """
+ handler, if present, must reference a function or method that
+ takes one argument: a Request object. If handler is not
+ specified at creation time, Server *must* be subclassed.
+ (The handler method below is abstract.)
+
+ maxwrite is the maximum number of bytes (per Record) to write
+ to the server. I've noticed mod_fastcgi has a relatively small
+ receive buffer (8K or so).
+
+ bindAddress, if present, must either be a string or a 2-tuple. If
+ present, run() will open its own listening socket. You would use
+ this if you wanted to run your application as an 'external' FastCGI
+ app. (i.e. the webserver would no longer be responsible for starting
+ your app) If a string, it will be interpreted as a filename and a UNIX
+ socket will be opened. If a tuple, the first element, a string,
+ is the interface name/IP to bind to, and the second element (an int)
+ is the port number.
+
+ Set multiplexed to True if you want to handle multiple requests
+ per connection. Some FastCGI backends (namely mod_fastcgi) don't
+ multiplex requests at all, so by default this is off (which saves
+ on thread creation/locking overhead). If threads aren't available,
+ this keyword is ignored; it's not possible to multiplex requests
+ at all.
+ """
+ if handler is not None:
+ self.handler = handler
+ self.maxwrite = maxwrite
+ if thread_available:
+ try:
+ import resource
+ # Attempt to glean the maximum number of connections
+ # from the OS.
+ maxConns = resource.getrlimit(resource.RLIMIT_NOFILE)[0]
+ except ImportError:
+ maxConns = 100 # Just some made up number.
+ maxReqs = maxConns
+ if multiplexed:
+ self._connectionClass = MultiplexedConnection
+ maxReqs *= 5 # Another made up number.
+ else:
+ self._connectionClass = Connection
+ self.capability = {
+ FCGI_MAX_CONNS: maxConns,
+ FCGI_MAX_REQS: maxReqs,
+ FCGI_MPXS_CONNS: multiplexed and 1 or 0
+ }
+ else:
+ self._connectionClass = Connection
+ self.capability = {
+ # If threads aren't available, these are pretty much correct.
+ FCGI_MAX_CONNS: 1,
+ FCGI_MAX_REQS: 1,
+ FCGI_MPXS_CONNS: 0
+ }
+ self._bindAddress = bindAddress
+ self._umask = umask
+
+ def _setupSocket(self):
+ if self._bindAddress is None: # Run as a normal FastCGI?
+ isFCGI = True
+
+ if isFCGI:
+ sock = socket.fromfd(FCGI_LISTENSOCK_FILENO, socket.AF_INET,
+ socket.SOCK_STREAM)
+ try:
+ sock.getpeername()
+ except socket.error, e:
+ if e[0] == errno.ENOTSOCK:
+ # Not a socket, assume CGI context.
+ isFCGI = False
+ elif e[0] != errno.ENOTCONN:
+ raise
+
+ # FastCGI/CGI discrimination is broken on Mac OS X.
+ # Set the environment variable FCGI_FORCE_CGI to "Y" or "y"
+ # if you want to run your app as a simple CGI. (You can do
+ # this with Apache's mod_env [not loaded by default in OS X
+ # client, ha ha] and the SetEnv directive.)
+ if not isFCGI or \
+ os.environ.get('FCGI_FORCE_CGI', 'N').upper().startswith('Y'):
+ req = self.cgirequest_class(self)
+ req.run()
+ sys.exit(0)
+ else:
+ # Run as a server
+ oldUmask = None
+ if type(self._bindAddress) is str:
+ # Unix socket
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ try:
+ os.unlink(self._bindAddress)
+ except OSError:
+ pass
+ if self._umask is not None:
+ oldUmask = os.umask(self._umask)
+ else:
+ # INET socket
+ assert type(self._bindAddress) is tuple
+ assert len(self._bindAddress) == 2
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+
+ sock.bind(self._bindAddress)
+ sock.listen(socket.SOMAXCONN)
+
+ if oldUmask is not None:
+ os.umask(oldUmask)
+
+ return sock
+
+ def _cleanupSocket(self, sock):
+ """Closes the main socket."""
+ sock.close()
+
+ def _installSignalHandlers(self):
+ self._oldSIGs = [(x,signal.getsignal(x)) for x in
+ (signal.SIGHUP, signal.SIGINT, signal.SIGTERM)]
+ signal.signal(signal.SIGHUP, self._hupHandler)
+ signal.signal(signal.SIGINT, self._intHandler)
+ signal.signal(signal.SIGTERM, self._intHandler)
+
+ def _restoreSignalHandlers(self):
+ for signum,handler in self._oldSIGs:
+ signal.signal(signum, handler)
+
+ def _hupHandler(self, signum, frame):
+ self._hupReceived = True
+ self._keepGoing = False
+
+ def _intHandler(self, signum, frame):
+ self._keepGoing = False
+
+ def run(self, timeout=1.0):
+ """
+ The main loop. Exits on SIGHUP, SIGINT, SIGTERM. Returns True if
+ SIGHUP was received, False otherwise.
+ """
+ web_server_addrs = os.environ.get('FCGI_WEB_SERVER_ADDRS')
+ if web_server_addrs is not None:
+ web_server_addrs = map(lambda x: x.strip(),
+ web_server_addrs.split(','))
+
+ sock = self._setupSocket()
+
+ self._keepGoing = True
+ self._hupReceived = False
+
+ # Install signal handlers.
+ self._installSignalHandlers()
+
+ while self._keepGoing:
+ try:
+ r, w, e = select.select([sock], [], [], timeout)
+ except select.error, e:
+ if e[0] == errno.EINTR:
+ continue
+ raise
+
+ if r:
+ try:
+ clientSock, addr = sock.accept()
+ except socket.error, e:
+ if e[0] in (errno.EINTR, errno.EAGAIN):
+ continue
+ raise
+
+ if web_server_addrs and \
+ (len(addr) != 2 or addr[0] not in web_server_addrs):
+ clientSock.close()
+ continue
+
+ # Instantiate a new Connection and begin processing FastCGI
+ # messages (either in a new thread or this thread).
+ conn = self._connectionClass(clientSock, addr, self)
+ thread.start_new_thread(conn.run, ())
+
+ self._mainloopPeriodic()
+
+ # Restore signal handlers.
+ self._restoreSignalHandlers()
+
+ self._cleanupSocket(sock)
+
+ return self._hupReceived
+
+ def _mainloopPeriodic(self):
+ """
+ Called with just about each iteration of the main loop. Meant to
+ be overridden.
+ """
+ pass
+
+ def _exit(self, reload=False):
+ """
+ Protected convenience method for subclasses to force an exit. Not
+ really thread-safe, which is why it isn't public.
+ """
+ if self._keepGoing:
+ self._keepGoing = False
+ self._hupReceived = reload
+
+ def handler(self, req):
+ """
+ Default handler, which just raises an exception. Unless a handler
+ is passed at initialization time, this must be implemented by
+ a subclass.
+ """
+ raise NotImplementedError, self.__class__.__name__ + '.handler'
+
+ def error(self, req):
+ """
+ Called by Request if an exception occurs within the handler. May and
+ should be overridden.
+ """
+ import cgitb
+ req.stdout.write('Content-Type: text/html\r\n\r\n' +
+ cgitb.html(sys.exc_info()))
+
+class WSGIServer(Server):
+ """
+ FastCGI server that supports the Web Server Gateway Interface. See
+ <http://www.python.org/peps/pep-0333.html>.
+ """
+ def __init__(self, application, environ=None, umask=None,
+ multithreaded=True, **kw):
+ """
+ environ, if present, must be a dictionary-like object. Its
+ contents will be copied into application's environ. Useful
+ for passing application-specific variables.
+
+ Set multithreaded to False if your application is not MT-safe.
+ """
+ if kw.has_key('handler'):
+ del kw['handler'] # Doesn't make sense to let this through
+ super(WSGIServer, self).__init__(**kw)
+
+ if environ is None:
+ environ = {}
+
+ self.application = application
+ self.environ = environ
+ self.multithreaded = multithreaded
+
+ # Used to force single-threadedness
+ self._app_lock = thread.allocate_lock()
+
+ def handler(self, req):
+ """Special handler for WSGI."""
+ if req.role != FCGI_RESPONDER:
+ return FCGI_UNKNOWN_ROLE, 0
+
+ # Mostly taken from example CGI gateway.
+ environ = req.params
+ environ.update(self.environ)
+
+ environ['wsgi.version'] = (1,0)
+ environ['wsgi.input'] = req.stdin
+ if self._bindAddress is None:
+ stderr = req.stderr
+ else:
+ stderr = TeeOutputStream((sys.stderr, req.stderr))
+ environ['wsgi.errors'] = stderr
+ environ['wsgi.multithread'] = not isinstance(req, CGIRequest) and \
+ thread_available and self.multithreaded
+ # Rationale for the following: If started by the web server
+ # (self._bindAddress is None) in either FastCGI or CGI mode, the
+ # possibility of being spawned multiple times simultaneously is quite
+ # real. And, if started as an external server, multiple copies may be
+ # spawned for load-balancing/redundancy. (Though I don't think
+ # mod_fastcgi supports this?)
+ environ['wsgi.multiprocess'] = True
+ environ['wsgi.run_once'] = isinstance(req, CGIRequest)
+
+ if environ.get('HTTPS', 'off') in ('on', '1'):
+ environ['wsgi.url_scheme'] = 'https'
+ else:
+ environ['wsgi.url_scheme'] = 'http'
+
+ self._sanitizeEnv(environ)
+
+ headers_set = []
+ headers_sent = []
+ result = None
+
+ def write(data):
+ assert type(data) is str, 'write() argument must be string'
+ assert headers_set, 'write() before start_response()'
+
+ if not headers_sent:
+ status, responseHeaders = headers_sent[:] = headers_set
+ found = False
+ for header,value in responseHeaders:
+ if header.lower() == 'content-length':
+ found = True
+ break
+ if not found and result is not None:
+ try:
+ if len(result) == 1:
+ responseHeaders.append(('Content-Length',
+ str(len(data))))
+ except:
+ pass
+ s = 'Status: %s\r\n' % status
+ for header in responseHeaders:
+ s += '%s: %s\r\n' % header
+ s += '\r\n'
+ req.stdout.write(s)
+
+ req.stdout.write(data)
+ req.stdout.flush()
+
+ def start_response(status, response_headers, exc_info=None):
+ if exc_info:
+ try:
+ if headers_sent:
+ # Re-raise if too late
+ raise exc_info[0], exc_info[1], exc_info[2]
+ finally:
+ exc_info = None # avoid dangling circular ref
+ else:
+ assert not headers_set, 'Headers already set!'
+
+ assert type(status) is str, 'Status must be a string'
+ assert len(status) >= 4, 'Status must be at least 4 characters'
+ assert int(status[:3]), 'Status must begin with 3-digit code'
+ assert status[3] == ' ', 'Status must have a space after code'
+ assert type(response_headers) is list, 'Headers must be a list'
+ if __debug__:
+ for name,val in response_headers:
+ assert type(name) is str, 'Header names must be strings'
+ assert type(val) is str, 'Header values must be strings'
+
+ headers_set[:] = [status, response_headers]
+ return write
+
+ if not self.multithreaded:
+ self._app_lock.acquire()
+ try:
+ try:
+ result = self.application(environ, start_response)
+ try:
+ for data in result:
+ if data:
+ write(data)
+ if not headers_sent:
+ write('') # in case body was empty
+ finally:
+ if hasattr(result, 'close'):
+ result.close()
+ except socket.error, e:
+ if e[0] != errno.EPIPE:
+ raise # Don't let EPIPE propagate beyond server
+ finally:
+ if not self.multithreaded:
+ self._app_lock.release()
+
+ return FCGI_REQUEST_COMPLETE, 0
+
+ def _sanitizeEnv(self, environ):
+ """Ensure certain values are present, if required by WSGI."""
+ if not environ.has_key('SCRIPT_NAME'):
+ environ['SCRIPT_NAME'] = ''
+ if not environ.has_key('PATH_INFO'):
+ environ['PATH_INFO'] = ''
+
+ # If any of these are missing, it probably signifies a broken
+ # server...
+ for name,default in [('REQUEST_METHOD', 'GET'),
+ ('SERVER_NAME', 'localhost'),
+ ('SERVER_PORT', '80'),
+ ('SERVER_PROTOCOL', 'HTTP/1.0')]:
+ if not environ.has_key(name):
+ environ['wsgi.errors'].write('%s: missing FastCGI param %s '
+ 'required by WSGI!\n' %
+ (self.__class__.__name__, name))
+ environ[name] = default
+
+if __name__ == '__main__':
+ def test_app(environ, start_response):
+ """Probably not the most efficient example."""
+ import cgi
+ start_response('200 OK', [('Content-Type', 'text/html')])
+ yield '<html><head><title>Hello World!</title></head>\n' \
+ '<body>\n' \
+ '<p>Hello World!</p>\n' \
+ '<table border="1">'
+ names = environ.keys()
+ names.sort()
+ for name in names:
+ yield '<tr><td>%s</td><td>%s</td></tr>\n' % (
+ name, cgi.escape(`environ[name]`))
+
+ form = cgi.FieldStorage(fp=environ['wsgi.input'], environ=environ,
+ keep_blank_values=1)
+ if form.list:
+ yield '<tr><th colspan="2">Form data</th></tr>'
+
+ for field in form.list:
+ yield '<tr><td>%s</td><td>%s</td></tr>\n' % (
+ field.name, field.value)
+
+ yield '</table>\n' \
+ '</body></html>\n'
+
+ WSGIServer(test_app).run()
diff --git a/cgi/formatting.py b/cgi/formatting.py
new file mode 100644
index 0000000..d21bee2
--- /dev/null
+++ b/cgi/formatting.py
@@ -0,0 +1,425 @@
+# coding=utf-8
+import string
+import cgi
+import os
+import re
+import pickle
+import time
+import _mysql
+
+from database import *
+from framework import *
+from post import regenerateAccess
+#from xhtml_clean import Cleaner
+
+from settings import Settings
+
+def format_post(message, ip, parentid, parent_timestamp=0):
+ """
+ Formats posts using the specified format
+ """
+ board = Settings._.BOARD
+ using_markdown = False
+
+ # Escape any HTML if user is not using Markdown or HTML
+ if not Settings.USE_HTML:
+ message = cgi.escape(message)
+
+ # Strip text
+ message = message.rstrip()[0:8000]
+
+ # Treat HTML
+ if Settings.USE_MARKDOWN:
+ message = markdown(message)
+ using_markdown = True
+ if Settings.USE_HTML:
+ message = onlyAllowedHTML(message)
+
+ # [code] tag
+ if board["dir"] == "tech":
+ message = re.compile(r"\[code\](.+)\[/code\]", re.DOTALL | re.IGNORECASE).sub(r"<pre><code>\1</code></pre>", message)
+ if board["allow_spoilers"]:
+ message = re.compile(r"\[spoiler\](.+)\[/spoiler\]", re.DOTALL | re.IGNORECASE).sub(r'<span class="spoil">\1</span>', message)
+
+ if Settings.VIDEO_THUMBS:
+ (message, affected) = videoThumbs(message)
+ if affected:
+ message = close_html(message)
+
+ message = clickableURLs(message)
+ message = checkRefLinks(message, parentid, parent_timestamp)
+ message = checkWordfilters(message, ip, board["dir"])
+
+ # If not using markdown quotes must be created and \n changed for HTML line breaks
+ if not using_markdown:
+ message = re.compile(r"^(\n)+").sub('', message)
+ message = checkQuotes(message)
+ message = message.replace("\n", "<br />")
+
+ return message
+
+def tripcode(name):
+ """
+ Calculate tripcode to match output of most imageboards
+ """
+ if name == '':
+ return '', ''
+
+ board = Settings._.BOARD
+
+ name = name.decode('utf-8')
+ key = Settings.TRIP_CHAR.decode('utf-8')
+
+ # if there's a trip
+ (namepart, marker, trippart) = name.partition('#')
+ if marker:
+ namepart = cleanString(namepart)
+ trip = ''
+
+ # secure tripcode
+ if Settings.ALLOW_SECURE_TRIPCODES and '#' in trippart:
+ (trippart, securemarker, securepart) = trippart.partition('#')
+ try:
+ securepart = securepart.encode("sjis", "ignore")
+ except:
+ pass
+
+ # encode secure tripcode
+ trip = getMD5(securepart + Settings.SECRET)
+ trip = trip.encode('base64').replace('\n', '')
+ trip = trip.encode('rot13')
+ trip = key+key+trip[2:12]
+
+ # return it if we don't have a normal tripcode
+ if trippart == '':
+ return namepart.encode('utf-8'), trip.encode('utf-8')
+
+ # do normal tripcode
+ from crypt import crypt
+ try:
+ trippart = trippart.encode("sjis", "ignore")
+ except:
+ pass
+
+ trippart = cleanString(trippart, True, True)
+ salt = re.sub(r"[^\.-z]", ".", (trippart + "H..")[1:3])
+ salt = salt.translate(string.maketrans(r":;=?@[\]^_`", "ABDFGabcdef"))
+ trip = key + crypt(trippart, salt)[-10:] + trip
+
+ return namepart.encode('utf-8'), trip.encode('utf-8')
+
+ return name.encode('utf-8'), ''
+
+def iphash(ip, post, t, useid, mobile, agent, cap_id, hide_end, has_countrycode):
+ current_t = time.time()
+
+ if cap_id:
+ id = cap_id
+ elif 'sage' in post['email'] and useid == '1':
+ id = '???'
+ elif ip == "127.0.0.1":
+ id = '???'
+ else:
+ day = int((current_t + (Settings.TIME_ZONE*3600)) / 86400)
+ word = ',' + str(day)
+
+ # Make difference by thread
+ word += ',' + str(t)
+
+ id = hide_data(ip + word, 6, "id", Settings.SECRET)
+
+ if hide_end:
+ id += '*'
+ elif addressIsTor(ip):
+ id += 'T'
+ elif 'Dalvik' in agent:
+ id += 'R'
+ elif 'Android' in agent:
+ id += 'a'
+ elif 'iPhone' in agent:
+ id += 'i'
+ elif useid == '3':
+ if 'Firefox' in agent:
+ id += 'F'
+ elif 'Safari' in agent and not 'Chrome' in agent:
+ id += 's'
+ elif 'Chrome' in agent:
+ id += 'C'
+ elif 'SeaMonkey' in agent:
+ id += 'S'
+ elif 'Edge' in agent:
+ id += 'E'
+ elif 'Opera' in agent or 'OPR' in agent:
+ id += 'o'
+ elif 'MSIE' in agent or 'Trident' in agent:
+ id += 'I'
+ elif mobile:
+ id += 'Q'
+ else:
+ id += '0'
+ elif mobile:
+ id += 'Q'
+ else:
+ id += '0'
+
+ if addressIsBanned(ip, ""):
+ id += '#'
+ if (not has_countrycode and
+ not addressIsTor(ip) and
+ (addressIsProxy(ip) or not addressIsES(ip))):
+ id += '!'
+
+ return id
+
+def cleanString(string, escape=True, quote=False):
+ string = string.strip()
+ if escape:
+ string = cgi.escape(string, quote)
+ return string
+
+def clickableURLs(message):
+ # URL
+ message = re.compile(r'( |^|:|\(|\[)((?:https?://|ftp://|mailto:|news:|irc:)[^\s<>()"]*?(?:\([^\s<>()"]*?\)[^\s<>()"]*?)*)((?:\s|<|>|"|\.|\|\]|!|\?|,|&#44;|&quot;)*(?:[\s<>()"]|$))', re.M).sub(r'\1<a href="\2" rel="nofollow" target="_blank">\2</a>\3', message)
+ # Emails
+ message = re.compile(r"( |^|:)([A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,6})", re.I | re.M).sub(r'\1<a href="mailto:\2" rel="nofollow">&lt;\2&gt;</a>', message)
+
+ return message
+
+def videoThumbs(message):
+ # Youtube
+ __RE = re.compile(r"^(?: +)?(https?://(?:www\.)?youtu(?:be\.com/watch\?v=|\.be/)([\w\-]+))(?: +)?$", re.M)
+ matches = __RE.finditer(message)
+ if matches:
+ import json
+ import urllib, urllib2
+
+ v_ids = []
+ videos = {}
+
+ for match in matches:
+ v_id = match.group(2)
+ if v_id not in v_ids:
+ v_ids.append(v_id)
+ videos[v_id] = {
+ 'span': match.span(0),
+ 'url': match.group(1),
+ }
+ if len(v_ids) >= Settings.VIDEO_THUMBS_LIMIT:
+ raise UserError, "Has incluído muchos videos en tu mensaje. El máximo es %d." % Settings.VIDEO_THUMBS_LIMIT
+
+ if videos:
+ params = {
+ 'key': Settings.GOOGLE_API_KEY,
+ 'part': 'snippet,contentDetails',
+ 'id': ','.join(v_ids)
+ }
+ r_url = "https://www.googleapis.com/youtube/v3/videos?"+urllib.urlencode(params)
+ res = urllib2.urlopen(r_url)
+ res_json = json.load(res)
+
+ offset = 0
+ for item in res_json['items']:
+ v_id = item['id']
+ (start, end) = videos[v_id]['span']
+ end += 1 # remove endline
+
+ try:
+ new_url = '<a href="%(url)s" target="_blank" class="yt"><span class="pvw"><img src="%(thumb)s" /></span><b>%(title)s</b> (%(secs)s)<br />%(channel)s</a><br />' \
+ % {'title': item['snippet']['title'].encode('utf-8'),
+ 'channel': item['snippet']['channelTitle'].encode('utf-8'),
+ 'secs': parseIsoPeriod(item['contentDetails']['duration']).encode('utf-8'),
+ 'url': videos[v_id]['url'],
+ 'id': v_id.encode('utf-8'),
+ 'thumb': item['snippet']['thumbnails']['default']['url'].encode('utf-8'),}
+ except UnicodeDecodeError:
+ raise UserError, repr(v_id)
+ message = message[:start+offset] + new_url + message[end+offset:]
+ offset += len(new_url) - (end-start)
+
+ return (message, len(videos))
+
+def fixMobileLinks(message):
+ """
+ Shorten long links; Convert >># links into a mobile version
+ """
+ board = Settings._.BOARD
+
+ # If textboard
+ if board["board_type"] == '1':
+ message = re.compile(r'<a href="/(\w+)/read/(\d+)(\.html)?/*(.+)"').sub(r'<a href="/cgi/mobileread/\1/\2/\4"', message)
+ else:
+ message = re.compile(r'<a href="/(\w+)/res/(\d+)\.html#(\d+)"').sub(r'<a href="/cgi/mobileread/\1/\2#\3"', message)
+
+ return message
+
+def checkRefLinks(message, parentid, parent_timestamp):
+ """
+ Check for >># links in posts and replace with the HTML to make them clickable
+ """
+ board = Settings._.BOARD
+
+ if board["board_type"] == '1':
+ # Textboard
+ if parentid != '0':
+ message = re.compile(r'&gt;&gt;(\d+(,\d+|-(?=[ \d\n])|\d+)*n?)').sub('<a href="' + Settings.BOARDS_URL + board['dir'] + '/read/' + str(parent_timestamp) + r'/\1">&gt;&gt;\1</a>', message)
+ else:
+ # Imageboard
+ quotes_id_array = re.findall(r"&gt;&gt;([0-9]+)", message)
+ for quotes in quotes_id_array:
+ try:
+ post = FetchOne('SELECT * FROM `posts` WHERE `id` = ' + quotes + ' AND `boardid` = ' + board['id'] + ' LIMIT 1')
+ if post['parentid'] != '0':
+ message = re.compile("&gt;&gt;" + quotes).sub('<a href="' + Settings.BOARDS_URL + board['dir'] + '/res/' + post['parentid'] + '.html#' + quotes + '">&gt;&gt;' + quotes + '</a>', message)
+ else:
+ message = re.compile("&gt;&gt;" + quotes).sub('<a href="' + Settings.BOARDS_URL + board['dir'] + '/res/' + post['id'] + '.html#' + quotes + '">&gt;&gt;' + quotes + '</a>', message)
+ except:
+ message = re.compile("&gt;&gt;" + quotes).sub(r'<span class="q">&gt;&gt;'+quotes+'</span>', message)
+
+ return message
+
+def checkQuotes(message):
+ """
+ Check for >text in posts and add span around it to color according to the css
+ """
+ message = re.compile(r"^&gt;(.*)$", re.MULTILINE).sub(r'<span class="q">&gt;\1</span>', message)
+ return message
+
+def escapeHTML(string):
+ string = string.replace('<', '&lt;')
+ string = string.replace('>', '&gt;')
+ return string
+
+def onlyAllowedHTML(message):
+ """
+ Allow <b>, <i>, <u>, <strike>, and <pre> in posts, along with the special <aa>
+ """
+ message = sanitize_html(message)
+ #message = re.compile(r"\[aa\](.+?)\[/aa\]", re.DOTALL | re.IGNORECASE).sub("<span class=\"sjis\">\\1</span>", message)
+
+ return message
+
+def close_html(message):
+ """
+ Old retarded version of sanitize_html, it just closes open tags.
+ """
+ import BeautifulSoup
+ return unicode(BeautifulSoup.BeautifulSoup(message)).replace('&#13;', '').encode('utf-8')
+
+def sanitize_html(message, decode=True):
+ """
+ Clean the code and allow only a few safe tags.
+ """
+ import BeautifulSoup
+
+ # Decode message from utf-8 if required
+ if decode:
+ message = message.decode('utf-8', 'replace')
+
+ # Create HTML Cleaner with our allowed tags
+ whitelist_tags = ["a","b","br","blink","code","del","em","i","marquee","root","strike","strong","sub","sup","u"]
+ whitelist_attr = ["href"]
+
+ soup = BeautifulSoup.BeautifulSoup(message)
+
+ # Remove tags that aren't allowed
+ for tag in soup.findAll():
+ if not tag.name.lower() in whitelist_tags:
+ tag.name = "span"
+ tag.attrs = []
+ else:
+ for attr in [attr for attr in tag.attrs if attr not in whitelist_attr]:
+ del tag[attr]
+
+ # We export the soup into a correct XHTML string
+ string = unicode(soup).encode('utf-8')
+ # We remove some anomalies we don't want
+ string = string.replace('<br/>', '<br />').replace('&#13;', '')
+
+ return string
+
+def markdown(message):
+ import markdown
+ if message.strip() != "":
+ #return markdown.markdown(message).rstrip("\n").rstrip("<br />")
+ return markdown.markdown(message, extras=["cuddled-lists", "code-friendly"]).encode('utf-8')
+ else:
+ return ""
+
+def checkWordfilters(message, ip, board):
+ fixed_ip = inet_aton(ip)
+ wordfilters = FetchAll("SELECT * FROM `filters` WHERE `type` = '0' ORDER BY `id` ASC")
+ for wordfilter in wordfilters:
+ if wordfilter["boards"] != "":
+ boards = pickle.loads(wordfilter["boards"])
+ if wordfilter["boards"] == "" or board in boards:
+ if wordfilter['action'] == '0':
+ if not re.search(wordfilter['from'], message, re.DOTALL | re.IGNORECASE) is None:
+ raise UserError, wordfilter['reason']
+ elif wordfilter['action'] == '1':
+ message = re.compile(wordfilter['from'], re.DOTALL | re.IGNORECASE).sub(wordfilter['to'], message)
+ elif wordfilter['action'] == '2':
+ # Ban
+ if not re.search(wordfilter['from'], message, re.DOTALL | re.IGNORECASE) is None:
+ if wordfilter['seconds'] != '0':
+ until = str(timestamp() + int(wordfilter['seconds']))
+ else:
+ until = '0'
+
+ InsertDb("INSERT INTO `bans` (`ip`, `boards`, `added`, `until`, `staff`, `reason`, `note`, `blind`) VALUES (" + \
+ "'" + str(fixed_ip) + "', '" + _mysql.escape_string(wordfilter['boards']) + \
+ "', " + str(timestamp()) + ", " + until + ", 'System', '" + _mysql.escape_string(wordfilter['reason']) + \
+ "', 'Word Auto-ban', '"+_mysql.escape_string(wordfilter['blind'])+"')")
+ regenerateAccess()
+ raise UserError, wordfilter['reason']
+ elif wordfilter['action'] == '3':
+ if not re.search(wordfilter['from'], message, re.DOTALL | re.IGNORECASE) is None:
+ raise UserError, '<meta http-equiv="refresh" content="%s;url=%s" />%s' % (wordfilter['redirect_time'], wordfilter['redirect_url'], wordfilter['reason'])
+ return message
+
+def checkNamefilters(name, tripcode, ip, board):
+ namefilters = FetchAll("SELECT * FROM `filters` WHERE `type` = '1'")
+
+ for namefilter in namefilters:
+ if namefilter["boards"] != "":
+ boards = pickle.loads(namefilter["boards"])
+ if namefilter["boards"] == "" or board in boards:
+ # check if this filter applies
+ match = False
+
+ if namefilter['from'] and namefilter['from_trip']:
+ # both name and trip filter
+ if re.search(namefilter['from'], name, re.DOTALL | re.IGNORECASE) and tripcode == namefilter['from_trip']:
+ match = True
+ elif namefilter['from'] and not namefilter['from_trip']:
+ # name filter
+ if re.search(namefilter['from'], name, re.DOTALL | re.IGNORECASE):
+ match = True
+ elif not namefilter['from'] and namefilter['from_trip']:
+ # trip filter
+ if tripcode == namefilter['from_trip']:
+ match = True
+
+ if match:
+ # do action
+ if namefilter['action'] == '0':
+ raise UserError, namefilter['reason']
+ elif namefilter['action'] == '1':
+ name = namefilter['to']
+ tripcode = ''
+ return name, tripcode
+ elif namefilter['action'] == '2':
+ # Ban
+ if namefilter['seconds'] != '0':
+ until = str(timestamp() + int(namefilter['seconds']))
+ else:
+ until = '0'
+
+ InsertDb("INSERT INTO `bans` (`ip`, `boards`, `added`, `until`, `staff`, `reason`, `note`, `blind`) VALUES (" + \
+ "'" + _mysql.escape_string(ip) + "', '" + _mysql.escape_string(namefilter['boards']) + \
+ "', " + str(timestamp()) + ", " + until + ", 'System', '" + _mysql.escape_string(namefilter['reason']) + \
+ "', 'Name Auto-ban', '"+_mysql.escape_string(namefilter['blind'])+"')")
+ regenerateAccess()
+ raise UserError, namefilter['reason']
+ elif namefilter['action'] == '3':
+ raise UserError, '<meta http-equiv="refresh" content="%s;url=%s" />%s' % (namefilter['redirect_time'], namefilter['redirect_url'], namefilter['reason'])
+ return name, tripcode
diff --git a/cgi/framework.py b/cgi/framework.py
new file mode 100644
index 0000000..4c89bb7
--- /dev/null
+++ b/cgi/framework.py
@@ -0,0 +1,467 @@
+# coding=utf-8
+import os
+import cgi
+import datetime
+import time
+import hashlib
+import pickle
+import socket
+import _mysql
+import urllib
+import re
+from Cookie import SimpleCookie
+
+from settings import Settings
+from database import *
+
+class CLT(datetime.tzinfo):
+ """
+ Clase para zona horaria chilena.
+ Como el gobierno nos tiene los horarios de verano para la pura cagá,
+ por mientras dejo el DST como un boolean. Cuando lo fijen, dejarlo automático.
+ """
+ def __init__(self):
+ self.isdst = False
+
+ def utcoffset(self, dt):
+ #return datetime.timedelta(hours=-3) + self.dst(dt)
+ return datetime.timedelta(hours=Settings.TIME_ZONE)
+
+ def dst(self, dt):
+ if self.isdst:
+ return datetime.timedelta(hours=1)
+ else:
+ return datetime.timedelta(0)
+
+ def tzname(self,dt):
+ return "GMT -3"
+
+def setBoard(dir):
+ """
+ Sets the board which the script is operating on by filling Settings._.BOARD
+ with the data from the db.
+ """
+ if not dir:
+ raise UserError, _("The specified board is invalid.")
+ logTime("Seteando el board " + dir)
+ board = FetchOne("SELECT * FROM `boards` WHERE `dir` = '%s' LIMIT 1" % _mysql.escape_string(dir))
+ if not board:
+ raise UserError, _("The specified board is invalid.")
+
+ board["filetypes"] = FetchAll("SELECT * FROM `boards_filetypes` INNER JOIN `filetypes` ON filetypes.id = boards_filetypes.filetypeid WHERE `boardid` = %s ORDER BY `ext` ASC" % _mysql.escape_string(board['id']))
+ board["filetypes_ext"] = [filetype['ext'] for filetype in board['filetypes']]
+ logTime("Board seteado.")
+
+ Settings._.BOARD = board
+
+ return board
+
+def addressIsBanned(ip, board):
+ packed_ip = inet_aton(ip)
+ bans = FetchAll("SELECT * FROM `bans` WHERE (`netmask` IS NULL AND `ip` = '"+str(packed_ip)+"') OR (`netmask` IS NOT NULL AND '"+str(packed_ip)+"' & `netmask` = `ip`)")
+ logTime("SELECT * FROM `bans` WHERE (`netmask` IS NULL AND `ip` = '"+str(packed_ip)+"') OR (`netmask` IS NOT NULL AND '"+str(packed_ip)+"' & `netmask` = `ip`)")
+ for ban in bans:
+ if ban["boards"] != "":
+ boards = pickle.loads(ban["boards"])
+ if ban["boards"] == "" or board in boards:
+ if board not in Settings.EXCLUDE_GLOBAL_BANS:
+ return True
+ return False
+
+def addressIsTor(ip):
+ if Settings._.IS_TOR is None:
+ res = False
+ nodes = []
+ if ip == '127.0.0.1': # Tor proxy address
+ res = True
+ else:
+ with open('tor.txt') as f:
+ nodes = [line.rstrip() for line in f]
+ if ip in nodes:
+ res = True
+ Settings._.IS_TOR = res
+ return res
+ else:
+ return Settings._.IS_TOR
+
+def addressIsProxy(ip):
+ if Settings._.IS_PROXY is None:
+ res = False
+ proxies = []
+ with open('proxy.txt') as f:
+ proxies = [line.rstrip() for line in f]
+ if ip in proxies:
+ res = True
+ Settings._.IS_PROXY = res
+ return res
+ else:
+ return Settings._.IS_PROXY
+
+def addressIsES(ip):
+ ES = ['AR', 'BO', 'CL', 'CO', 'CR', 'CU', 'EC', 'ES', 'GF',
+ 'GY', 'GT', 'HN', 'MX', 'NI', 'PA', 'PE', 'PY', 'PR', 'SR', 'UY', 'VE'] # 'BR',
+ return getCountry(ip) in ES
+
+def getCountry(ip):
+ import geoip
+ return geoip.country(ip)
+
+def getHost(ip):
+ if Settings._.HOST is None:
+ try:
+ Settings._.HOST = socket.gethostbyaddr(ip)[0]
+ return Settings._.HOST
+ except socket.herror:
+ return None
+ else:
+ return Settings._.HOST
+
+def hostIsBanned(ip):
+ host = getHost(ip)
+ if host:
+ banned_hosts = []
+ for banned_host in banned_hosts:
+ if host.endswith(banned_host):
+ return True
+ return False
+ else:
+ return False
+
+def updateBoardSettings():
+ """
+ Pickle the board's settings and store it in the configuration field
+ """
+ board = Settings._.BOARD
+ #UpdateDb("UPDATE `boards` SET `configuration` = '%s' WHERE `id` = %s LIMIT 1" % (_mysql.escape_string(configuration), board["id"]))
+
+ del board["filetypes"]
+ del board["filetypes_ext"]
+ post_values = ["`" + _mysql.escape_string(str(key)) + "` = '" + _mysql.escape_string(str(value)) + "'" for key, value in board.iteritems()]
+
+ UpdateDb("UPDATE `boards` SET %s WHERE `id` = '%s' LIMIT 1" % (", ".join(post_values), board["id"]))
+
+def timestamp(t=None):
+ """
+ Create MySQL-safe timestamp from the datetime t if provided, otherwise create
+ the timestamp from datetime.now()
+ """
+ if not t:
+ t = datetime.datetime.now()
+ return int(time.mktime(t.timetuple()))
+
+def formatDate(t=None, home=False):
+ """
+ Format a datetime to a readable date
+ """
+ if not t:
+ t = datetime.datetime.now(CLT())
+ # Timezone fix
+ #t += datetime.timedelta(hours=1)
+
+ days = {'en': ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'],
+ 'es': ['lun', 'mar', 'mie', 'jue', 'vie', 'sab', 'dom'],
+ 'jp': ['月', '火', '水', '木', '金', '土', '日']}
+
+ daylist = days[Settings.LANG]
+ format = "%d/%m/%y(%a)%H:%M:%S"
+
+ if not home:
+ try:
+ board = Settings._.BOARD
+ if board["dir"] == 'world':
+ daylist = days['en']
+ elif board["dir"] == '2d':
+ daylist = days['jp']
+ except:
+ pass
+
+ t = t.strftime(format)
+
+ t = re.compile(r"mon", re.DOTALL | re.IGNORECASE).sub(daylist[0], t)
+ t = re.compile(r"tue", re.DOTALL | re.IGNORECASE).sub(daylist[1], t)
+ t = re.compile(r"wed", re.DOTALL | re.IGNORECASE).sub(daylist[2], t)
+ t = re.compile(r"thu", re.DOTALL | re.IGNORECASE).sub(daylist[3], t)
+ t = re.compile(r"fri", re.DOTALL | re.IGNORECASE).sub(daylist[4], t)
+ t = re.compile(r"sat", re.DOTALL | re.IGNORECASE).sub(daylist[5], t)
+ t = re.compile(r"sun", re.DOTALL | re.IGNORECASE).sub(daylist[6], t)
+ return t
+
+def formatTimestamp(t, home=False):
+ """
+ Format a timestamp to a readable date
+ """
+ return formatDate(datetime.datetime.fromtimestamp(int(t), CLT()), home)
+
+def timeTaken(time_start, time_finish):
+ return str(round(time_finish - time_start, 3))
+
+def parseIsoPeriod(t_str):
+ m = re.match('P(?:(\d+)D)?T(?:(\d+)H)?(?:(\d+)M)?(\d+)S', t_str)
+ if m:
+ grps = [x for x in m.groups() if x]
+ if len(grps) == 1:
+ grps.insert(0, '0')
+ grps[-1] = grps[-1].zfill(2)
+ return ':'.join(grps)
+ else:
+ return '???'
+
+def getFormData(self):
+ """
+ Process input sent to WSGI through a POST method and output it in an easy to
+ retrieve format: dictionary of dictionaries in the format of {key: value}
+ """
+ wsgi_input = self.environ["wsgi.input"]
+ post_form = self.environ.get("wsgi.post_form")
+ if (post_form is not None
+ and post_form[0] is wsgi_input):
+ return post_form[2]
+ # This must be done to avoid a bug in cgi.FieldStorage
+ self.environ.setdefault("QUERY_STRING", "")
+ fs = cgi.FieldStorage(fp=wsgi_input,
+ environ=self.environ,
+ keep_blank_values=1)
+ new_input = InputProcessed()
+ post_form = (new_input, wsgi_input, fs)
+ self.environ["wsgi.post_form"] = post_form
+ self.environ["wsgi.input"] = new_input
+
+ formdata = {}
+ for key in dict(fs):
+ try:
+ formdata.update({key: fs[key].value})
+ if key == "file":
+ formdata.update({"file_original": secure_filename(fs[key].filename)})
+ except AttributeError:
+ formdata.update({key: fs[key]})
+
+ return formdata
+
+class InputProcessed(object):
+ def read(self):
+ raise EOFError("El stream de wsgi.input ya se ha consumido.")
+ readline = readlines = __iter__ = read
+
+class UserError(Exception):
+ pass
+
+def secure_filename(path):
+ split = re.compile(r'[\0%s]' % re.escape(''.join([os.path.sep, os.path.altsep or ''])))
+ return cgi.escape(split.sub('', path))
+
+def getMD5(data):
+ m = hashlib.md5()
+ m.update(data)
+
+ return m.hexdigest()
+
+def nullstr(len): return "\0" * len
+
+def hide_data(data, length, key, secret):
+ """
+ Encrypts data, useful for tripcodes and IDs
+ """
+ crypt = rc4(nullstr(length), rc4(nullstr(32), key + secret) + data).encode('base64')
+ return crypt.rstrip('\n')
+
+def rc4(data, key):
+ """
+ rc4 implementation
+ """
+ x = 0
+ box = range(256)
+ for i in range(256):
+ x = (x + box[i] + ord(key[i % len(key)])) % 256
+ box[i], box[x] = box[x], box[i]
+ x = 0
+ y = 0
+ out = []
+ for char in data:
+ x = (x + 1) % 256
+ y = (y + box[x]) % 256
+ box[x], box[y] = box[y], box[x]
+ out.append(chr(ord(char) ^ box[(box[x] + box[y]) % 256]))
+
+ return ''.join(out)
+
+def getRandomLine(filename):
+ import random
+ f = open(filename, 'r')
+ lines = f.readlines()
+ num = random.randint(0, len(lines) - 1)
+ return lines[num]
+
+def getRandomIco():
+ from glob import glob
+ from random import choice
+ icons = glob("../static/ico/*")
+ if icons:
+ return choice(icons).lstrip('..')
+ else:
+ return ''
+
+def N_(message): return message
+
+def getCookie(self, value=""):
+ return urllib.unquote_plus(self._cookies[value].value)
+
+def reCookie(self, key, value=""):
+ board = Settings._.BOARD
+ setCookie(self, key, value)
+
+def setCookie(self, key, value="", max_age=None, expires=None, path="/", domain=None, secure=None):
+ """
+ Copied from Colubrid
+ """
+ if self._cookies is None:
+ self._cookies = SimpleCookie()
+ self._cookies[key] = urllib.quote_plus(value)
+ if not max_age is None:
+ self._cookies[key]["max-age"] = max_age
+ if not expires is None:
+ if isinstance(expires, basestring):
+ self._cookies[key]["expires"] = expires
+ expires = None
+ elif isinstance(expires, datetime):
+ expires = expires.utctimetuple()
+ elif not isinstance(expires, (int, long)):
+ expires = datetime.datetime.gmtime(expires)
+ else:
+ raise ValueError("Se requiere de un entero o un datetime")
+ if not expires is None:
+ now = datetime.datetime.gmtime()
+ month = _([N_("Jan"), N_("Feb"), N_("Mar"), N_("Apr"), N_("May"), N_("Jun"), N_("Jul"),
+ N_("Aug"), N_("Sep"), N_("Oct"), N_("Nov"), N_("Dec")][now.tm_mon - 1])
+ day = _([N_("Monday"), N_("Tuesday"), N_("Wednesday"), N_("Thursday"),
+ N_("Friday"), N_("Saturday"), N_("Sunday")][expires.tm_wday])
+ date = "%02d-%s-%s" % (
+ now.tm_mday, month, str(now.tm_year)[-2:]
+ )
+ d = "%s, %s %02d:%02d:%02d GMT" % (day, date, now.tm_hour,
+ now.tm_min, now.tm_sec)
+ self._cookies[key]["expires"] = d
+ if not path is None:
+ self._cookies[key]["path"] = path
+ if not domain is None:
+ if domain != "THIS":
+ self._cookies[key]["domain"] = domain
+ else:
+ self._cookies[key]["domain"] = Settings.DOMAIN
+ if not secure is None:
+ self._cookies[key]["secure"] = secure
+
+def deleteCookie(self, key):
+ """
+ Copied from Colubrid
+ """
+ if self._cookies is None:
+ self._cookies = SimpleCookie()
+ if not key in self._cookies:
+ self._cookies[key] = ""
+ self._cookies[key]["max-age"] = 0
+
+def elapsed_time(seconds, suffixes=['y','w','d','h','m','s'], add_s=False, separator=' '):
+ """
+ Takes an amount of seconds and turns it into a human-readable amount of time.
+ """
+ # the formatted time string to be returned
+ time = []
+
+ # the pieces of time to iterate over (days, hours, minutes, etc)
+ # - the first piece in each tuple is the suffix (d, h, w)
+ # - the second piece is the length in seconds (a day is 60s * 60m * 24h)
+ parts = [(suffixes[0], 60 * 60 * 24 * 7 * 52),
+ (suffixes[1], 60 * 60 * 24 * 7),
+ (suffixes[2], 60 * 60 * 24),
+ (suffixes[3], 60 * 60),
+ (suffixes[4], 60),
+ (suffixes[5], 1)]
+
+ # for each time piece, grab the value and remaining seconds, and add it to
+ # the time string
+ for suffix, length in parts:
+ value = seconds / length
+ if value > 0:
+ seconds = seconds % length
+ time.append('%s%s' % (str(value),
+ (suffix, (suffix, suffix + 's')[value > 1])[add_s]))
+ if seconds < 1:
+ break
+
+ return separator.join(time)
+
+def inet_aton(ip_string):
+ import socket, struct
+ return struct.unpack('!L',socket.inet_aton(ip_string))[0]
+
+def inet_ntoa(packed_ip):
+ import socket, struct
+ return socket.inet_ntoa(struct.pack('!L',packed_ip))
+
+def is_bad_proxy(pip):
+ import urllib2
+ import socket
+ socket.setdefaulttimeout(3)
+
+ try:
+ proxy_handler = urllib2.ProxyHandler({'http': pip})
+ opener = urllib2.build_opener(proxy_handler)
+ opener.addheaders = [('User-agent', 'Mozilla/5.0')]
+ urllib2.install_opener(opener)
+ req=urllib2.Request('http://bienvenidoainternet.org')
+ sock=urllib2.urlopen(req)
+ except urllib2.HTTPError, e:
+ return e.code
+ except Exception, detail:
+ return True
+ return False
+
+def send_mail(subject, srcmsg):
+ import smtplib
+ from email.mime.text import MIMEText
+
+ msg = MIMEText(srcmsg)
+ me = 'weabot@bienvenidoainternet.org'
+ you = 'burocracia@bienvenidoainternet.org'
+
+ msg['Subject'] = 'The contents of %s' % textfile
+ msg['From'] = me
+ msg['To'] = you
+
+ s = smtplib.SMTP('localhost')
+ s.sendmail(me, [you], msg.as_string())
+ s.quit()
+
+class weabotLogger:
+ def __init__(self):
+ self.times = []
+
+ def log(self, message):
+ self.times.append([time.time(), message])
+
+ def allTimes(self):
+ output = "Time Logged action\n--------------------------\n"
+ start = self.times[0][0]
+ for time in self.times:
+ difference = str(time[0] - start)
+ difference_split = difference.split(".")
+ if len(difference_split[0]) < 2:
+ difference_split[0] = "0" + difference_split[0]
+
+ if len(difference_split[1]) < 7:
+ difference_split[1] = ("0" * (7 - len(difference_split[1]))) + difference_split[1]
+ elif len(difference_split[1]) > 7:
+ difference_split[1] = difference_split[1][:7]
+
+ output += ".".join(difference_split) + " " + time[1] + "\n"
+
+ return output
+
+logger = weabotLogger()
+def logTime(message):
+ global logger
+ logger.log(message)
+
+def logTimes():
+ global logger
+ return logger.allTimes()
diff --git a/cgi/geoip.py b/cgi/geoip.py
new file mode 100644
index 0000000..0bcb3d8
--- /dev/null
+++ b/cgi/geoip.py
@@ -0,0 +1,128 @@
+"""Python API that wraps GeoIP country database lookup into a simple function.
+
+Download the latest MaxMind GeoIP country database and read other docs here:
+ http://www.maxmind.com/app/geolitecountry
+
+Copyright (C) 2009 Ben Hoyt, released under the Lesser General Public License:
+ http://www.gnu.org/licenses/lgpl.txt
+
+Usage examples:
+
+>>> country('64.233.161.99')
+'US'
+>>> country('202.21.128.102')
+'NZ'
+>>> country('asdf')
+''
+>>> country('127.0.0.1')
+''
+"""
+
+# List of country codes (indexed by GeoIP country ID number)
+countries = (
+ '', 'AP', 'EU', 'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AN', 'AO', 'AQ',
+ 'AR', 'AS', 'AT', 'AU', 'AW', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH',
+ 'BI', 'BJ', 'BM', 'BN', 'BO', 'BR', 'BS', 'BT', 'BV', 'BW', 'BY', 'BZ', 'CA',
+ 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU',
+ 'CV', 'CX', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG',
+ 'EH', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'FX', 'GA', 'GB',
+ 'GD', 'GE', 'GF', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT',
+ 'GU', 'GW', 'GY', 'HK', 'HM', 'HN', 'HR', 'HT', 'HU', 'ID', 'IE', 'IL', 'IN',
+ 'IO', 'IQ', 'IR', 'IS', 'IT', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM',
+ 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS',
+ 'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'MG', 'MH', 'MK', 'ML', 'MM', 'MN',
+ 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA',
+ 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA',
+ 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY',
+ 'QA', 'RE', 'RO', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI',
+ 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR', 'ST', 'SV', 'SY', 'SZ', 'TC', 'TD',
+ 'TF', 'TG', 'TH', 'TJ', 'TK', 'TM', 'TN', 'TO', 'TL', 'TR', 'TT', 'TV', 'TW',
+ 'TZ', 'UA', 'UG', 'UM', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE', 'VG', 'VI', 'VN',
+ 'VU', 'WF', 'WS', 'YE', 'YT', 'RS', 'ZA', 'ZM', 'ME', 'ZW', 'A1', 'A2', 'O1',
+ 'AX', 'GG', 'IM', 'JE', 'BL', 'MF')
+
+def iptonum(ip):
+ """Convert IP address string to 32-bit integer, or return None if IP is bad.
+
+ >>> iptonum('0.0.0.0')
+ 0
+ >>> hex(iptonum('127.0.0.1'))
+ '0x7f000001'
+ >>> hex(iptonum('255.255.255.255'))
+ '0xffffffffL'
+ >>> iptonum('127.0.0.256')
+ >>> iptonum('1.2.3')
+ >>> iptonum('a.s.d.f')
+ >>> iptonum('1.2.3.-4')
+ >>> iptonum('')
+ """
+ segments = ip.split('.')
+ if len(segments) != 4:
+ return None
+ num = 0
+ for segment in segments:
+ try:
+ segment = int(segment)
+ except ValueError:
+ return None
+ if segment < 0 or segment > 255:
+ return None
+ num = num << 8 | segment
+ return num
+
+class DatabaseError(Exception):
+ pass
+
+class GeoIP(object):
+ """Wraps GeoIP country database lookup into a class."""
+
+ _record_length = 3
+ _country_start = 16776960
+
+ def __init__(self, dbname='GeoIP.dat'):
+ """Init GeoIP instance with given GeoIP country database file."""
+ self._dbfile = open(dbname, 'rb')
+
+ def country(self, ip):
+ """Lookup IP address string and turn it into a two-letter country code
+ like 'NZ', or return empty string if unknown.
+
+ >>> g = GeoIP()
+ >>> g.country('64.233.161.99')
+ 'US'
+ >>> g.country('202.21.128.102')
+ 'NZ'
+ >>> g.country('asdf')
+ ''
+ >>> g.country('127.0.0.1')
+ ''
+ """
+ ipnum = iptonum(ip)
+ if ipnum is None:
+ return ''
+ return countries[self._country_id(ipnum)]
+
+ def _country_id(self, ipnum):
+ """Look up and return country ID of given 32-bit IP address."""
+ # Search algorithm from: http://code.google.com/p/pygeoip/
+ offset = 0
+ for depth in range(31, -1, -1):
+ self._dbfile.seek(offset * 2 * self._record_length)
+ data = self._dbfile.read(2 * self._record_length)
+ x = [0, 0]
+ for i in range(2):
+ for j in range(self._record_length):
+ x[i] += ord(data[self._record_length * i + j]) << (j * 8)
+ i = 1 if ipnum & (1 << depth) else 0
+ if x[i] >= self._country_start:
+ return x[i] - self._country_start
+ offset = x[i]
+ raise DatabaseError('GeoIP database corrupt: offset=%s' % offset)
+
+def country(ip, dbname='GeoIP.dat'):
+ """Helper function that creates a GeoIP instance and calls country()."""
+ return GeoIP(dbname).country(ip)
+
+if __name__ == '__main__':
+ import doctest
+ doctest.testmod()
diff --git a/cgi/img.py b/cgi/img.py
new file mode 100644
index 0000000..21f326a
--- /dev/null
+++ b/cgi/img.py
@@ -0,0 +1,416 @@
+# coding=utf-8
+import struct
+import math
+#import random
+import os
+import subprocess
+from StringIO import StringIO
+
+from settings import Settings
+from database import *
+from framework import *
+
+try: # Windows needs stdio set for binary mode.
+ import msvcrt
+ msvcrt.setmode (0, os.O_BINARY) # stdin = 0
+ msvcrt.setmode (1, os.O_BINARY) # stdout = 1
+except ImportError:
+ pass
+
+def processImage(post, data, t, originalname, spoiler=False):
+ """
+ Take all post data from <post>, process uploaded file in <data>, and calculate
+ file names using datetime <t>
+ Returns updated <post> with file and thumb values
+ """
+ board = Settings._.BOARD
+
+ used_filetype = None
+
+ # get image information
+ content_type, width, height, size, extra = getImageInfo(data)
+
+ # check the size is fine
+ if size > int(board["maxsize"])*1024:
+ raise UserError, _("File too big. The maximum file size is: %s") % board['maxsize']
+
+ # check if file is supported
+ for filetype in board['filetypes']:
+ if content_type == filetype['mime']:
+ used_filetype = filetype
+ break
+
+ if not used_filetype:
+ raise UserError, _("File type not supported.")
+
+ # check if file is already posted
+ is_duplicate = checkFileDuplicate(data)
+ if checkFileDuplicate(data)[0]:
+ raise UserError, _("This image has already been posted %s.") % ('<a href="' + Settings.BOARDS_URL + board['dir'] + '/res/' + str(is_duplicate[1]) + '.html#' + str(is_duplicate[2]) + '">' + _("here") + '</a>')
+
+ # prepare file names
+ if used_filetype['preserve_name'] == '1':
+ file_base = os.path.splitext(originalname)[0] # use original filename
+ else:
+ file_base = '%d' % int(t * 1000) # generate timestamp name
+ file_name = file_base + "." + used_filetype['ext']
+ file_thumb_name = file_base + "s.jpg"
+
+ # prepare paths
+ file_path = Settings.IMAGES_DIR + board["dir"] + "/src/" + file_name
+ file_thumb_path = Settings.IMAGES_DIR + board["dir"] + "/thumb/" + file_thumb_name
+ file_mobile_path = Settings.IMAGES_DIR + board["dir"] + "/mobile/" + file_thumb_name
+ file_cat_path = Settings.IMAGES_DIR + board["dir"] + "/cat/" + file_thumb_name
+
+ # remove EXIF data if necessary for privacy
+ if content_type == 'image/jpeg':
+ data = removeExifData(data)
+
+ # write file
+ f = open(file_path, "wb")
+ try:
+ f.write(data)
+ finally:
+ f.close()
+
+ # set maximum dimensions
+ maxsize = int(board['thumb_px'])
+
+ post["file"] = file_name
+ post["image_width"] = width
+ post["image_height"] = height
+
+ # Do we need to thumbnail it?
+ if not used_filetype['image']:
+ # make thumbnail
+ file_thumb_width, file_thumb_height = getThumbDimensions(width, height, maxsize)
+
+ if used_filetype['ffmpeg_thumb'] == '1':
+ # use ffmpeg to make thumbnail
+ logTime("Generating thumbnail")
+
+ if used_filetype['mime'][:5] == 'video':
+ #duration_half = str(int(extra['duration'] / 2))
+ retcode = subprocess.call([
+ Settings.FFMPEG_PATH, '-strict', '-2', '-ss', '0', '-i', file_path,
+ '-v', 'quiet', '-an', '-vframes', '1', '-f', 'mjpeg', '-vf', 'scale=%d:%d' % (file_thumb_width, file_thumb_height),
+ '-threads', '1', file_thumb_path])
+ if spoiler:
+ args = [Settings.CONVERT_PATH, file_thumb_path, "-limit", "thread", "1", "-background", "white", "-flatten", "-resize", "%dx%d" % (file_thumb_width, file_thumb_height), "-blur", "0x12", "-gravity", "center", "-fill", "rgba(0,0,0, .6)", "-draw", "rectangle 0,%d,%d,%d" % ((file_thumb_height/2)-10, file_thumb_width, (file_thumb_height/2)+7), "-fill", "white", "-annotate", "0", "Alerta de spoiler", "-quality", str(Settings.THUMB_QUALITY), file_thumb_path]
+ retcode = subprocess.call(args)
+ elif used_filetype['mime'][:5] == 'audio':
+ # we do an exception and use png for audio waveform thumbnails since they
+ # 1. are smaller 2. allow for transparency
+ file_thumb_name = file_thumb_name[:-3] + "png"
+ file_thumb_path = file_thumb_path[:-3] + "png"
+ file_mobile_path = file_mobile_path[:-3] + "png"
+ file_cat_path = file_cat_path[:-3] + "png"
+
+ if int(board['thumb_px']) > 149:
+ file_thumb_width = board['thumb_px']
+ file_thumb_height = float(int(board['thumb_px'])/2)
+ else:
+ file_thumb_width = 150
+ file_thumb_height = 75
+
+ retcode = subprocess.call([
+ Settings.FFMPEG_PATH, '-t', '300', '-i', file_path,
+ '-filter_complex', 'showwavespic=s=%dx%d:split_channels=1' % (int(file_thumb_width), int(file_thumb_height)),
+ '-frames:v', '1', '-threads', '1', file_thumb_path])
+# elif used_filetype['mime'] == 'application/x-shockwave-flash' or used_filetype['mime'] == 'mime/x-shockwave-flash':
+# retcode = subprocess.call([
+# './ffmpeg', '-i', file_path, '-vcodec', 'mjpeg', '-vframes', '1', '-an', '-f', 'rawvideo',
+# '-vf', 'scale=%d:%d' % (file_thumb_width, file_thumb_height), '-threads', '1', file_thumb_path])
+
+ if retcode != 0:
+ os.remove(file_path)
+ raise UserError, _("Thumbnail creation failure.") + ' ('+str(retcode)+')'
+ else:
+ # use imagemagick to make thumbnail
+ args = [Settings.CONVERT_PATH, file_path, "-limit", "thread", "1", "-background", "white", "-flatten", "-resize", "%dx%d" % (file_thumb_width, file_thumb_height)]
+ if spoiler:
+ args += ["-blur", "0x12", "-gravity", "center", "-fill", "rgba(0,0,0, .6)", "-draw", "rectangle 0,%d,%d,%d" % ((file_thumb_height/2)-10, file_thumb_width, (file_thumb_height/2)+7), "-fill", "white", "-annotate", "0", "Alerta de spoiler"]
+ args += ["-quality", str(Settings.THUMB_QUALITY), file_thumb_path]
+
+ # generate thumbnails
+ logTime("Generating thumbnail")
+ retcode = subprocess.call(args)
+ if retcode != 0:
+ os.remove(file_path)
+ raise UserError, _("Thumbnail creation failure.") + ' ('+str(retcode)+')'
+
+ # check if thumbnail was truly created
+ try:
+ open(file_thumb_path)
+ except:
+ os.remove(file_path)
+ raise UserError, _("Thumbnail creation failure.")
+
+ # create extra thumbnails (catalog/mobile)
+ subprocess.call([Settings.CONVERT_PATH, file_thumb_path, "-limit" , "thread", "1", "-resize", "100x100", "-quality", "75", file_mobile_path])
+ if not post["parentid"]:
+ subprocess.call([Settings.CONVERT_PATH, file_thumb_path, "-limit" , "thread", "1", "-resize", "150x150", "-quality", "60", file_cat_path])
+
+ post["thumb"] = file_thumb_name
+ post["thumb_width"] = file_thumb_width
+ post["thumb_height"] = file_thumb_height
+ else:
+ # Don't thumbnail and use mime image
+ if board["board_type"] == '0':
+ post["thumb"] = used_filetype['image']
+ post["thumb_width"] = '120'
+ post["thumb_height"] = '120'
+ else:
+ post["thumb"] = used_filetype['image'].split(".")[0] + '_small.png'
+ post["thumb_width"] = '90'
+ post["thumb_height"] = '90'
+
+ # calculate size (bytes)
+ post["file_size"] = len(data)
+
+ # add additional metadata, if any
+ post["message"] += extraInfo(content_type, file_name, file_path)
+
+ # file md5
+ post["file_hex"] = getMD5(data)
+
+ return post
+
+def extraInfo(mime, file_name, file_path):
+ board = Settings._.BOARD
+
+ if mime in ['audio/ogg', 'audio/opus', 'audio/mpeg', 'video/webm']:
+ info = ffprobe_f(file_path)
+ extra = {}
+ credit_str = ""
+
+ if mime == 'video/webm':
+ for s in info['streams']:
+ if 'width' in s:
+ stream = s
+ else:
+ stream = info['streams'][0]
+
+ extra['codec'] = stream.get('codec_name', '').encode('utf-8')
+ format = info['format']
+
+ if 'bit_rate' in format:
+ extra['codec'] += ' ~%d kbps' % int(int(format['bit_rate']) / 1000)
+ if 'tags' in format:
+ extra['title'] = format['tags'].get('TITLE', format['tags'].get('title', '')).encode('utf-8')
+ extra['artist'] = format['tags'].get('ARTIST', format['tags'].get('artist', '')).encode('utf-8')
+ if extra['title'] or extra['artist']:
+ credit_str = ' - '.join((extra['artist'], extra['title'])) + ' '
+ if 'tags' in stream:
+ extra['title'] = stream['tags'].get('TITLE', '').encode('utf-8')
+ extra['artist'] = stream['tags'].get('ARTIST', '').encode('utf-8')
+ if extra['title'] or extra['artist']:
+ credit_str = ' - '.join((extra['artist'], extra['title'])) + ' '
+
+ return '<hr /><small>%s(%s)</small>' % (credit_str, extra['codec'])
+
+ elif mime in ['audio/mod', 'audio/xm', 'audio/s3m']:
+ ext = mime.split('/')[1].upper()
+ url = '/cgi/play/%s/%s' % (board['dir'], file_name)
+ return '<hr /><small>Módulo tracker (%s) [<a href="%s" target="_blank">Click para escuchar</a>]</small>' % (ext, url)
+
+ return ''
+
+def getImageInfo(data):
+ data = str(data)
+ size = len(data)
+ height = -1
+ width = -1
+ extra = {}
+ content_type = ""
+
+ # handle GIFs
+ if (size >= 10) and data[:6] in ("GIF87a", "GIF89a"):
+ # Check to see if content_type is correct
+ content_type = "image/gif"
+ w, h = struct.unpack("<HH", data[6:10])
+ width = int(w)
+ height = int(h)
+
+ # See PNG 2. Edition spec (http://www.w3.org/TR/PNG/)
+ # Bytes 0-7 are below, 4-byte chunk length, then 'IHDR'
+ # and finally the 4-byte width, height
+ elif ((size >= 24) and data.startswith("\211PNG\r\n\032\n")
+ and (data[12:16] == "IHDR")):
+ content_type = "image/png"
+ w, h = struct.unpack(">LL", data[16:24])
+ width = int(w)
+ height = int(h)
+
+ # Maybe this is for an older PNG version.
+ elif (size >= 16) and data.startswith("\211PNG\r\n\032\n"):
+ # Check to see if we have the right content type
+ content_type = "image/png"
+ w, h = struct.unpack(">LL", data[8:16])
+ width = int(w)
+ height = int(h)
+
+ # handle JPEGs
+ elif (size >= 2) and data.startswith("\377\330"):
+ content_type = "image/jpeg"
+ jpeg = StringIO(data)
+ jpeg.read(2)
+ b = jpeg.read(1)
+ try:
+ while (b and ord(b) != 0xDA):
+ while (ord(b) != 0xFF): b = jpeg.read
+ while (ord(b) == 0xFF): b = jpeg.read(1)
+ if (ord(b) >= 0xC0 and ord(b) <= 0xC3):
+ jpeg.read(3)
+ h, w = struct.unpack(">HH", jpeg.read(4))
+ break
+ else:
+ jpeg.read(int(struct.unpack(">H", jpeg.read(2))[0])-2)
+ b = jpeg.read(1)
+ width = int(w)
+ height = int(h)
+ except struct.error:
+ pass
+ except ValueError:
+ pass
+
+ # handle WebM
+ elif (size >= 4) and data.startswith("\x1A\x45\xDF\xA3"):
+ content_type = "video/webm"
+ info = ffprobe(data)
+
+ for stream in info['streams']:
+ if 'width' in stream:
+ width = stream['width']
+ height = stream['height']
+ break
+
+ extra['duration'] = float(info['format']['duration'])
+
+ # handle ogg formats (vorbis/opus)
+ elif (size >= 64) and data[:4] == "OggS":
+ if data[28:35] == "\x01vorbis":
+ content_type = "audio/ogg"
+ elif data[28:36] == "OpusHead":
+ content_type = "audio/opus"
+
+ # handle MP3
+ elif (size >= 64) and (data[:3] == "ID3" or data[:3] == "\xFF\xFB"):
+ content_type = "audio/mpeg"
+
+ # handle MOD
+ elif (size >= 64) and data[1080:1084] == "M.K.":
+ content_type = "audio/mod"
+
+ # handle XM
+ elif (size >= 64) and data.startswith("Extended Module:"):
+ content_type = "audio/xm"
+
+ # handle S3M
+ elif (size >= 64) and data[25:32] == "\x00\x00\x00\x1A\x10\x00\x00":
+ content_type = "audio/s3m"
+
+ # handle PDF
+ elif (size >= 4) and data[:7] == "%PDF-1.":
+ content_type = "application/pdf"
+
+ # handle Shockwave Flash
+ elif (size >= 3) and data[:3] in ["CWS", "FWS"]:
+ content_type = "application/x-shockwave-flash"
+
+ # handle torrent
+ elif (size >= 11) and data[:11] == "d8:announce":
+ content_type = "application/x-bittorrent"
+
+ # handle PDF
+ elif (size >= 2) and data[:2] == "PK":
+ content_type = "application/epub+zip"
+
+ return content_type, width, height, size, extra
+
+def ffprobe(data):
+ import json
+ p = subprocess.Popen([Settings.FFPROBE_PATH, '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', '-'],
+ stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
+
+ out = p.communicate(input=data)[0]
+ return json.loads(out)
+
+def ffprobe_f(filename):
+ import json
+
+ p = subprocess.Popen([Settings.FFPROBE_PATH, '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filename],
+ stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
+
+ out = p.communicate()[0]
+ return json.loads(out)
+
+def getThumbDimensions(width, height, maxsize):
+ """
+ Calculate dimensions to use for a thumbnail with maximum width/height of
+ <maxsize>, keeping aspect ratio
+ """
+ wratio = (float(maxsize) / float(width))
+ hratio = (float(maxsize) / float(height))
+
+ if (width <= maxsize) and (height <= maxsize):
+ return width, height
+ else:
+ if (wratio * height) < maxsize:
+ thumb_height = math.ceil(wratio * height)
+ thumb_width = maxsize
+ else:
+ thumb_width = math.ceil(hratio * width)
+ thumb_height = maxsize
+
+ return int(thumb_width), int(thumb_height)
+
+def checkFileDuplicate(data):
+ """
+ Check that the file <data> does not already exist in a live post on the
+ current board by calculating its hex and checking it against the database
+ """
+ board = Settings._.BOARD
+
+ file_hex = getMD5(data)
+ post = FetchOne("SELECT `id`, `parentid` FROM `posts` WHERE `file_hex` = '%s' AND `boardid` = %s AND IS_DELETED = 0 LIMIT 1" % (file_hex, board['id']))
+ if post:
+ if int(post["parentid"]) != 0:
+ return True, post["parentid"], post["id"]
+ else:
+ return True, post["id"], post["id"]
+ else:
+ return False, 0, 0
+
+def getJpegSegments(data):
+ if data[0:2] != b"\xff\xd8":
+ raise UserError("Given data isn't JPEG.")
+
+ head = 2
+ segments = [b"\xff\xd8"]
+ while 1:
+ if data[head: head + 2] == b"\xff\xda":
+ yield data[head:]
+ break
+ else:
+ length = struct.unpack(">H", data[head + 2: head + 4])[0]
+ endPoint = head + length + 2
+ seg = data[head: endPoint]
+ yield seg
+ head = endPoint
+
+ if (head >= len(data)):
+ raise UserDataError("Wrong JPEG data.")
+
+def removeExifData(src_data):
+ exif = None
+
+ for seg in getJpegSegments(src_data):
+ if seg[0:2] == b"\xff\xe1" and seg[4:10] == b"Exif\x00\x00":
+ exif = seg
+ break
+
+ if exif:
+ return src_data.replace(exif, b"")
+ else:
+ return src_data
diff --git a/cgi/locale/es/LC_MESSAGES/weabot.mo b/cgi/locale/es/LC_MESSAGES/weabot.mo
new file mode 100644
index 0000000..8e207a5
--- /dev/null
+++ b/cgi/locale/es/LC_MESSAGES/weabot.mo
Binary files differ
diff --git a/cgi/manage.py b/cgi/manage.py
new file mode 100644
index 0000000..44731ba
--- /dev/null
+++ b/cgi/manage.py
@@ -0,0 +1,1823 @@
+# coding=utf-8
+import _mysql
+import os
+import cgi
+import shutil
+import imaplib
+import poplib
+import datetime
+
+from database import *
+from settings import Settings
+from framework import *
+from formatting import *
+from template import *
+from post import *
+
+def manage(self, path_split):
+ page = ''
+ validated = False
+ administrator = False
+ moderator = True
+ skiptemplate = False
+
+ try:
+ if self.formdata['username'] and self.formdata['password']:
+ # If no admin accounts available, create admin:admin
+ first_admin = FetchOne("SELECT 1 FROM `staff` WHERE `rights` = 0 LIMIT 1", 0)
+ if not first_admin:
+ InsertDb("INSERT INTO `staff` (`username`, `password`, `added`, `rights`) VALUES ('admin', '" + _mysql.escape_string(genPasswd("admin")) + "', 0, 0)")
+
+ password = genPasswd(self.formdata['password'])
+
+ valid_account = FetchOne("SELECT * FROM `staff` WHERE `username` = '" + _mysql.escape_string(self.formdata['username']) + "' AND `password` = '" + _mysql.escape_string(password) + "' LIMIT 1")
+ if valid_account:
+ setCookie(self, 'weabot_manage', self.formdata['username'] + ':' + valid_account['password'], domain='THIS')
+ UpdateDb('DELETE FROM `logs` WHERE `timestamp` < ' + str(timestamp() - 604800)) # one week
+ else:
+ page += _('Incorrect username/password.')
+ logAction('', 'Failed log-in. U:'+_mysql.escape_string(self.formdata['username'])+' IP:'+self.environ["REMOTE_ADDR"])
+ except:
+ pass
+
+ try:
+ manage_cookie = getCookie(self, 'weabot_manage')
+ if manage_cookie != '':
+ username, password = manage_cookie.split(':')
+ staff_account = FetchOne("SELECT * FROM `staff` WHERE `username` = '" + _mysql.escape_string(username) + "' AND `password` = '" + _mysql.escape_string(password) + "' LIMIT 1")
+ if staff_account:
+ validated = True
+ if staff_account['rights'] == '0' or staff_account['rights'] == '1' or staff_account['rights'] == '2':
+ administrator = True
+ if staff_account['rights'] == '2':
+ moderator = False
+ UpdateDb('UPDATE `staff` SET `lastactive` = ' + str(timestamp()) + ' WHERE `id` = ' + staff_account['id'] + ' LIMIT 1')
+ except:
+ pass
+
+ #validated = True
+ #moderator = True
+ #staff_account = {}
+ #staff_account['username'] = ''
+ #staff_account['rights'] = '0'
+ #staff_account['added'] = '0'
+
+ if not validated:
+ template_filename = "login.html"
+ template_values = {}
+ else:
+ if len(path_split) > 2:
+ if path_split[2] == 'rebuild':
+ if not administrator:
+ return
+
+ try:
+ board_dir = path_split[3]
+ except:
+ board_dir = ''
+
+ if board_dir == '':
+ template_filename = "rebuild.html"
+ template_values = {'boards': boardlist()}
+ else:
+ everything = ("everything" in self.formdata)
+ if board_dir == '!ALL':
+ t1 = time.time()
+ boards = FetchAll('SELECT `dir` FROM `boards` WHERE secret = 0')
+ for board in boards:
+ board = setBoard(board['dir'])
+ regenerateBoard(everything)
+
+ message = _('Rebuilt %(board)s in %(time)s seconds.') % {'board': _('all boards'), 'time': timeTaken(t1, time.time())}
+ logAction(staff_account['username'], _('Rebuilt %s') % _('all boards'))
+ elif board_dir == '!BBS':
+ t1 = time.time()
+ boards = FetchAll('SELECT `dir` FROM `boards` WHERE `board_type` = 1')
+ for board in boards:
+ board = setBoard(board['dir'])
+ regenerateBoard(everything)
+
+ message = _('Rebuilt %(board)s in %(time)s seconds.') % {'board': _('all boards'), 'time': timeTaken(t1, time.time())}
+ logAction(staff_account['username'], _('Rebuilt %s') % _('all boards'))
+ elif board_dir == '!IB':
+ t1 = time.time()
+ boards = FetchAll('SELECT `dir` FROM `boards` WHERE `board_type` = 1')
+ for board in boards:
+ board = setBoard(board['dir'])
+ regenerateBoard(everything)
+
+ message = _('Rebuilt %(board)s in %(time)s seconds.') % {'board': _('all boards'), 'time': timeTaken(t1, time.time())}
+ logAction(staff_account['username'], _('Rebuilt %s') % _('all boards'))
+ elif board_dir == '!HOME':
+ t1 = time.time()
+ regenerateHome()
+ message = _('Rebuilt %(board)s in %(time)s seconds.') % {'board': _('home'), 'time': timeTaken(t1, time.time())}
+ logAction(staff_account['username'], _('Rebuilt %s') % _('home'))
+ elif board_dir == '!NEWS':
+ t1 = time.time()
+ regenerateNews()
+ message = _('Rebuilt %(board)s in %(time)s seconds.') % {'board': _('news'), 'time': timeTaken(t1, time.time())}
+ logAction(staff_account['username'], _('Rebuilt %s') % _('news'))
+ elif board_dir == '!KAKO':
+ t1 = time.time()
+ boards = FetchAll('SELECT `dir` FROM `boards` WHERE archive = 1')
+ for board in boards:
+ board = setBoard(board['dir'])
+ regenerateKako()
+
+ message = _('Rebuilt %(board)s in %(time)s seconds.') % {'board': 'kako', 'time': timeTaken(t1, time.time())}
+ logAction(staff_account['username'], _('Rebuilt %s') % 'kako')
+ elif board_dir == '!HTACCESS':
+ t1 = time.time()
+ if regenerateAccess():
+ message = _('Rebuilt %(board)s in %(time)s seconds.') % {'board': _('htaccess'), 'time': timeTaken(t1, time.time())}
+ logAction(staff_account['username'], _('Rebuilt %s') % _('htaccess'))
+ else:
+ message = _('htaccess regeneration deactivated by sysop.')
+ else:
+ t1 = time.time()
+ board = setBoard(board_dir)
+ regenerateBoard(everything)
+
+ message = _('Rebuilt %(board)s in %(time)s seconds.') % {'board': '/' + board['dir'] + '/', 'time': timeTaken(t1, time.time())}
+ logAction(staff_account['username'], 'Rebuilt /' + board['dir'] + '/')
+
+ template_filename = "message.html"
+ elif path_split[2] == 'mod':
+ if not moderator:
+ return
+
+ try:
+ board = setBoard(path_split[3])
+ except:
+ board = ""
+
+ if not board:
+ template_filename = "mod.html"
+ template_values = {"mode": 1, 'boards': boardlist()}
+ elif self.formdata.get("thread"):
+ parentid = int(self.formdata["thread"])
+ posts = FetchAll('SELECT id, timestamp, timestamp_formatted, name, message, file, thumb, IS_DELETED, locked, subject, length, INET_NTOA(ip) AS ip FROM `posts` WHERE (parentid = %d OR id = %d) AND boardid = %s ORDER BY `id` ASC' % (parentid, parentid, board['id']))
+ template_filename = "mod.html"
+ template_values = {"mode": 3, "dir": board["dir"], "posts": posts}
+ else:
+ threads = FetchAll("SELECT * FROM `posts` WHERE boardid = %s AND parentid = 0 ORDER BY `bumped` DESC" % board["id"])
+ template_filename = "mod.html"
+ template_values = {"mode": 2, "dir": board["dir"], "threads": threads}
+ elif path_split[2] == 'staff':
+ if staff_account['rights'] != '0':
+ return
+ action_taken = False
+
+ if len(path_split) > 3:
+ if path_split[3] == 'add' or path_split[3] == 'edit':
+ member = None
+ member_username = ''
+ member_rights = '3'
+
+ if path_split[3] == 'edit':
+ if len(path_split) > 4:
+ member = FetchOne('SELECT * FROM `staff` WHERE `id` = ' + _mysql.escape_string(path_split[4]) + ' LIMIT 1')
+ if member:
+ member_username = member['username']
+ member_rights = member['rights']
+ action = 'edit/' + member['id']
+
+ try:
+ if self.formdata['username'] != '':
+ if self.formdata['rights'] in ['0', '1', '2', '3']:
+ action_taken = True
+ if not ':' in self.formdata['username']:
+ UpdateDb("UPDATE `staff` SET `username` = '" + _mysql.escape_string(self.formdata['username']) + "', `rights` = " + self.formdata['rights'] + " WHERE `id` = " + member['id'] + " LIMIT 1")
+ message = _('Staff member updated.')
+ logAction(staff_account['username'], _('Updated staff account for %s') % self.formdata['username'])
+ else:
+ message = _('The character : can not be used in usernames.')
+ template_filename = "message.html"
+ except:
+ pass
+ else:
+ action = 'add'
+ try:
+ if self.formdata['username'] != '' and self.formdata['password'] != '':
+ username_taken = FetchOne('SELECT * FROM `staff` WHERE `username` = \'' + _mysql.escape_string(self.formdata['username']) + '\' LIMIT 1')
+ if not username_taken:
+ if self.formdata['rights'] in ['0', '1', '2', '3']:
+ action_taken = True
+ if not ':' in self.formdata['username']:
+ password = genPasswd(self.formdata['password'])
+
+ InsertDb("INSERT INTO `staff` (`username`, `password`, `added`, `rights`) VALUES ('" + _mysql.escape_string(self.formdata['username']) + "', '" + _mysql.escape_string(password) + "', " + str(timestamp()) + ", " + self.formdata['rights'] + ")")
+ message = _('Staff member added.')
+ logAction(staff_account['username'], 'Added staff account for ' + self.formdata['username'])
+ else:
+ message = _('The character : can not be used in usernames.')
+
+ template_filename = "message.html"
+ else:
+ action_taken = True
+ message = _('That username is already in use.')
+ template_filename = "message.html"
+ except:
+ pass
+
+ if not action_taken:
+ action_taken = True
+
+ if action == 'add':
+ submit = 'Agregar'
+ else:
+ submit = 'Editar'
+
+ template_filename = "staff.html"
+ template_values = {'mode': 1,
+ 'action': action,
+ 'member': member,
+ 'member_username': member_username,
+ 'member_rights': member_rights,
+ 'submit': submit}
+ elif path_split[3] == 'delete':
+ if not moderator:
+ return
+
+ action_taken = True
+ message = '<a href="' + Settings.CGI_URL + 'manage/staff/delete_confirmed/' + path_split[4] + '">' + _('Click here to confirm the deletion of that staff member') + '</a>'
+ template_filename = "message.html"
+ elif path_split[3] == 'delete_confirmed':
+ if not moderator:
+ return
+
+ try:
+ action_taken = True
+ member = FetchOne('SELECT `username` FROM `staff` WHERE `id` = ' + _mysql.escape_string(path_split[4]) + ' LIMIT 1')
+ if member:
+ UpdateDb('DELETE FROM `staff` WHERE `id` = ' + _mysql.escape_string(path_split[4]) + ' LIMIT 1')
+ message = 'Staff member deleted.'
+ template_filename = "message.html"
+ logAction(staff_account['username'], _('Deleted staff account for %s') % member['username'])
+ else:
+ message = _('Unable to locate a staff account with that ID.')
+ template_filename = "message.html"
+ except:
+ pass
+
+ if not action_taken:
+ staff = FetchAll('SELECT * FROM `staff` ORDER BY `rights`')
+ for member in staff:
+ if member['rights'] == '0':
+ member ['rights'] = _('Super-administrator')
+ elif member['rights'] == '1':
+ member ['rights'] = _('Administrator')
+ elif member['rights'] == '2':
+ member ['rights'] = _('Developer')
+ elif member['rights'] == '3':
+ member ['rights'] = _('Moderator')
+ if member['lastactive'] != '0':
+ member['lastactivestamp'] = member['lastactive']
+ member['lastactive'] = formatTimestamp(member['lastactive'])
+ else:
+ member['lastactive'] = _('Never')
+ member['lastactivestamp'] = '0'
+ template_filename = "staff.html"
+ template_values = {'mode': 0, 'staff': staff}
+ elif path_split[2] == 'delete':
+ if not moderator:
+ return
+
+ do_ban = False
+ try:
+ if self.formdata['ban'] == 'true':
+ do_ban = True
+ except:
+ pass
+
+ template_filename = "delete.html"
+ template_values = {'do_ban': do_ban, 'curboard': path_split[3], 'postid': path_split[4]}
+ elif path_split[2] == 'delete_confirmed':
+ if not moderator:
+ return
+
+ do_ban = self.formdata.get('ban')
+ permanently = self.formdata.get('perma')
+ imageonly = self.formdata.get('imageonly')
+
+ board = setBoard(path_split[3])
+ postid = int(path_split[4])
+ post = FetchOne('SELECT id, message, parentid, INET_NTOA(ip) AS ip FROM posts WHERE boardid = %s AND id = %s' % (board['id'], postid))
+
+ if not permanently:
+ deletePost(path_split[4], None, '2', imageonly)
+ else:
+ deletePost(path_split[4], None, '0', imageonly)
+ regenerateHome()
+
+ # Borrar denuncias
+ UpdateDb("DELETE FROM `reports` WHERE `postid` = '"+_mysql.escape_string(path_split[4])+"'")
+ boards = FetchAll('SELECT `name`, `dir` FROM `boards` ORDER BY `dir`')
+
+ if imageonly:
+ message = 'Archivo de post /%s/%s eliminado.' % (board['dir'], post['id'])
+ elif permanently or post["parentid"] == '0':
+ message = 'Post /%s/%s eliminado permanentemente.' % (board['dir'], post['id'])
+ else:
+ message = 'Post /%s/%s enviado a la papelera.' % (board['dir'], post['id'])
+ template_filename = "message.html"
+ logAction(staff_account['username'], message + ' Contenido: ' + post['message'] + ' IP: ' + post['ip'])
+
+ if do_ban:
+ message = _('Redirecting to ban page...') + '<meta http-equiv="refresh" content="0;url=' + Settings.CGI_URL + 'manage/ban?ip=' + post['ip'] + '" />'
+ template_filename = "message.html"
+ elif path_split[2] == 'lock':
+ setLocked = 0
+
+ # Nos vamos al board y ubicamos el post
+ board = setBoard(path_split[3])
+ post = FetchOne('SELECT `parentid`, `locked` FROM `posts` WHERE `boardid` = ' + board['id'] + ' AND `id` = \'' + _mysql.escape_string(path_split[4]) + '\' LIMIT 1')
+ if not post:
+ message = _('Unable to locate a post with that ID.')
+ template_filename = "message.html"
+ else:
+ if post['parentid'] != '0':
+ message = _('Post is not a thread opener.')
+ template_filename = "message.html"
+ else:
+ if post['locked'] == '0':
+ # Cerrar si esta abierto
+ setLocked = 1
+ else:
+ # Abrir si esta cerrado
+ setLocked = 0
+
+ UpdateDb("UPDATE `posts` SET `locked` = %d WHERE `boardid` = '%s' AND `id` = '%s' LIMIT 1" % (setLocked, board["id"], _mysql.escape_string(path_split[4])))
+ threadUpdated(path_split[4])
+ if setLocked == 1:
+ message = _('Thread successfully closed.')
+ logAction(staff_account['username'], _('Closed thread %s') % ('/' + path_split[3] + '/' + path_split[4]))
+ else:
+ message = _('Thread successfully opened.')
+ logAction(staff_account['username'], _('Opened thread %s') % ('/' + path_split[3] + '/' + path_split[4]))
+ template_filename = "message.html"
+ elif path_split[2] == 'permasage':
+ setPermasaged = 0
+
+ # Nos vamos al board y ubicamos el post
+ board = setBoard(path_split[3])
+ post = FetchOne('SELECT `parentid`, `locked` FROM `posts` WHERE `boardid` = ' + board['id'] + ' AND `id` = \'' + _mysql.escape_string(path_split[4]) + '\' LIMIT 1')
+ if not post:
+ message = 'Unable to locate a post with that ID.'
+ template_filename = "message.html"
+ elif post['locked'] == '1':
+ message = 'Solo se puede aplicar permasage en un hilo abierto.'
+ template_filename = "message.html"
+ else:
+ if post['parentid'] != '0':
+ message = 'Post is not a thread opener.'
+ template_filename = "message.html"
+ else:
+ if post['locked'] == '2':
+ # Sacar permasage
+ setPermasaged = 0
+ else:
+ # Colocar permasage
+ setPermasaged = 2
+
+ UpdateDb("UPDATE `posts` SET `locked` = %d WHERE `boardid` = '%s' AND `id` = '%s' LIMIT 1" % (setPermasaged, board["id"], _mysql.escape_string(path_split[4])))
+ regenerateFrontPages()
+ threadUpdated(path_split[4])
+
+ if setPermasaged == 2:
+ message = 'Thread successfully permasaged.'
+ logAction(staff_account['username'], 'Enabled permasage in thread /' + path_split[3] + '/' + path_split[4])
+ else:
+ message = 'Thread successfully un-permasaged.'
+ logAction(staff_account['username'], 'Disabled permasage in thread /' + path_split[3] + '/' + path_split[4])
+ template_filename = "message.html"
+ elif path_split[2] == 'move':
+ if not moderator:
+ return
+
+ oldboardid = ""
+ oldthread = ""
+ newboardid = ""
+ try:
+ oldboardid = path_split[3]
+ oldthread = path_split[4]
+ newboardid = path_split[5]
+ except:
+ pass
+
+ try:
+ oldboardid = self.formdata['oldboardid']
+ oldthread = self.formdata['oldthread']
+ newboardid = self.formdata['newboardid']
+ except:
+ pass
+
+ if oldboardid and oldthread and newboardid:
+ message = "import"
+ import shutil
+ message += "ok"
+
+ board = setBoard(oldboardid)
+ oldboard = board['dir']
+ oldboardsubject = board['subject']
+
+ # get old posts
+ posts = FetchAll("SELECT * FROM `posts` WHERE (`id` = {0} OR `parentid` = {0}) AND `boardid` = {1} ORDER BY id ASC".format(oldthread, board['id']))
+
+ # switch to new board
+ board = setBoard(newboardid)
+ newboard = board['dir']
+
+ refs = {}
+ moved_files = []
+ moved_thumbs = []
+ moved_cats = []
+ newthreadid = 0
+ newthread = 0
+ num = 1
+
+ message = "from total: %s<br>" % len(posts)
+ template_filename = "message.html"
+
+ for p in posts:
+ # save old post ID
+ old_id = p['id']
+ is_op = bool(p['parentid'] == '0')
+
+ # copy post object but without ID and target boardid
+ post = Post()
+ post.post = p
+ post.post.pop("id")
+ post["boardid"] = board['id']
+ post["parentid"] = newthreadid
+
+ # save the files we need to move if any
+ if post['IS_DELETED'] == '0':
+ if post['file']:
+ moved_files.append(post['file'])
+ if post['thumb']:
+ moved_thumbs.append(post['thumb'])
+ if is_op:
+ moved_cats.append(post['thumb'])
+
+ # fix subject if necessary
+ if post['subject'] and post['subject'] == oldboardsubject:
+ post['subject'] = board['subject']
+
+ # insert new post and get its new ID
+ new_id = post.insert()
+
+ # save the reference (BBS = post number, IB = new ID)
+ refs[old_id] = num if board['board_type'] == '1' else new_id
+
+ # this was an OP
+ message += "newthread = %s parentid = %s<br>" % (newthreadid, p['parentid'])
+ if is_op:
+ oldthread = old_id
+ newthreadid = new_id
+ oldbumped = post["bumped"]
+
+ # BBS = new thread timestamp, IB = new thread ID
+ newthread = post['timestamp'] if board['board_type'] == '1' else new_id
+
+ # log it
+ message += "%s -> %s<br>" % (old_id, new_id)
+
+ num += 1
+
+ # fix anchors
+ for old, new in refs.iteritems():
+ old_url = "/{oldboard}/res/{oldthread}.html#{oldpost}\">&gt;&gt;{oldpost}</a>".format(oldboard=oldboard, oldthread=oldthread, oldpost=old)
+
+ if board['board_type'] == '1':
+ new_url = "/{newboard}/read/{newthread}/{newpost}\">&gt;&gt;{newpost}</a>".format(newboard=newboard, newthread=newthread, newpost=new)
+ else:
+ new_url = "/{newboard}/res/{newthread}.html#{newpost}\">&gt;&gt;{newpost}</a>".format(newboard=newboard, newthread=newthread, newpost=new)
+
+ sql = "UPDATE `posts` SET `message` = REPLACE(message, '{old}', '{new}') WHERE `boardid` = {newboardid} AND (`id` = {newthreadid} OR `parentid` = {newthreadid})".format(old=old_url, new=new_url, newboardid=board['id'], newthreadid=newthreadid)
+ message += sql + "<br>"
+ UpdateDb(sql)
+
+ # copy files
+ for file in moved_files:
+ if not os.path.isfile(Settings.IMAGES_DIR + newboard + "/src/" + file):
+ shutil.copyfile(Settings.IMAGES_DIR + oldboard + "/src/" + file, Settings.IMAGES_DIR + newboard + "/src/" + file)
+ for thumb in moved_thumbs:
+ if not os.path.isfile(Settings.IMAGES_DIR + newboard + "/thumb/" + thumb):
+ shutil.copyfile(Settings.IMAGES_DIR + oldboard + "/thumb/" + thumb, Settings.IMAGES_DIR + newboard + "/thumb/" + thumb)
+ if not os.path.isfile(Settings.IMAGES_DIR + newboard + "/mobile/" + thumb):
+ shutil.copyfile(Settings.IMAGES_DIR + oldboard + "/mobile/" + thumb, Settings.IMAGES_DIR + newboard + "/mobile/" + thumb)
+ for cat in moved_cats:
+ try:
+ if not os.path.isfile(Settings.IMAGES_DIR + newboard + "/cat/" + thumb):
+ shutil.copyfile(Settings.IMAGES_DIR + oldboard + "/cat/" + thumb, Settings.IMAGES_DIR + newboard + "/cat/" + thumb)
+ except:
+ pass
+
+ # lock original, set expiration to 1 day
+ exp = timestamp()+86400
+ exp_format = datetime.datetime.fromtimestamp(exp).strftime("%d/%m")
+ sql = "UPDATE `posts` SET `locked`=1, `expires`={exp}, `expires_formatted`=\"{exp_format}\" WHERE `boardid`=\"{oldboard}\" AND id=\"{oldthread}\"".format(exp=exp,exp_format=exp_format,oldboard=oldboardid,oldthread=oldthread)
+ UpdateDb(sql)
+
+ # insert notice message
+ if 'msg' in self.formdata:
+ board = setBoard(oldboard)
+
+ if board['board_type'] == '1':
+ thread_url = "/{newboard}/read/{newthread}".format(newboard=newboard, newthread=newthread)
+ else:
+ thread_url = "/{newboard}/res/{newthread}.html".format(newboard=newboard, newthread=newthread)
+
+ notice_post = Post(board["id"])
+ notice_post["parentid"] = oldthread
+ if board['board_type'] == "0":
+ notice_post["subject"] = "Aviso"
+ notice_post["name"] = "Sistema"
+ notice_post["message"] = "El hilo ha sido movido a <a href=\"{url}\">/{newboard}/{newthread}</a>.".format(url=thread_url, newboard=newboard, newthread=newthread)
+ notice_post["timestamp"] = timestamp()+1
+ notice_post["timestamp_formatted"] = "Hilo movido"
+ notice_post["bumped"] = oldbumped
+ notice_post.insert()
+
+ # regenerate
+ regenerateFrontPages()
+ regenerateThreadPage(newthreadid)
+ regenerateThreadPage(oldthread)
+
+ message += "done"
+
+ logAction(staff_account['username'], "Movido hilo %s/%s a %s/%s." % (oldboard, oldthread, newboard, newthread))
+ else:
+ template_filename = "move.html"
+ template_values = {'boards': boardlist(), 'oldboardid': oldboardid, 'oldthread': oldthread}
+ elif path_split[2] == 'ban':
+ if not moderator:
+ return
+
+ if len(path_split) > 4:
+ board = setBoard(path_split[3])
+ post = FetchOne('SELECT `ip` FROM `posts` WHERE `boardid` = ' + board['id'] + ' AND `id` = \'' + _mysql.escape_string(path_split[4]) + '\' LIMIT 1')
+ formatted_ip = inet_ntoa(long(post['ip']))
+ #Creo que esto no deberia ir aqui... -> UpdateDb('UPDATE `posts` SET `banned` = 1 WHERE `boardid` = ' + board['id'] + ' AND `id` = \'' + _mysql.escape_string(path_split[4]) + '\'')
+ if not post:
+ message = _('Unable to locate a post with that ID.')
+ template_filename = "message.html"
+ else:
+ message = '<meta http-equiv="refresh" content="0;url=' + Settings.CGI_URL + 'manage/ban?ip=' + formatted_ip + '" />Espere...'
+ template_filename = "message.html"
+ else:
+ #if path_split[3] == '':
+ try:
+ ip = self.formdata['ip']
+ except:
+ ip = ''
+ try:
+ netmask = insnetmask = self.formdata['netmask']
+ if netmask == '255.255.255.255':
+ insnetmask = ''
+ except:
+ netmask = instnetmask = ''
+ #else:
+ # ip = path_split[3]
+ if ip != '':
+ try:
+ reason = self.formdata['reason']
+ except:
+ reason = None
+ if reason is not None:
+ if self.formdata['seconds'] != '0':
+ until = str(timestamp() + int(self.formdata['seconds']))
+ else:
+ until = '0'
+ where = ''
+ if 'board_all' not in self.formdata.keys():
+ where = []
+ boards = FetchAll('SELECT `dir` FROM `boards`')
+ for board in boards:
+ keyname = 'board_' + board['dir']
+ if keyname in self.formdata.keys():
+ if self.formdata[keyname] == "1":
+ where.append(board['dir'])
+ if len(where) > 0:
+ where = pickle.dumps(where)
+ else:
+ self.error(_("You must select where the ban shall be placed"))
+ return
+
+ if 'edit' in self.formdata.keys():
+ UpdateDb("DELETE FROM `bans` WHERE `id` = '" + _mysql.escape_string(self.formdata['edit']) + "' LIMIT 1")
+ else:
+ ban = FetchOne("SELECT `id` FROM `bans` WHERE `ip` = '" + _mysql.escape_string(ip) + "' AND `boards` = '" + _mysql.escape_string(where) + "' LIMIT 1")
+ if ban:
+ self.error(_('There is already an identical ban for this IP.') + '<a href="'+Settings.CGI_URL+'manage/ban/' + ip + '?edit=' + ban['id']+'">' + _('Edit') + '</a>')
+ return
+
+ # Blind mode
+ if 'blind' in self.formdata.keys() and self.formdata['blind'] == '1':
+ blind = '1'
+ else:
+ blind = '0'
+
+ # Banear sin mensaje
+ InsertDb("INSERT INTO `bans` (`ip`, `netmask`, `boards`, `added`, `until`, `staff`, `reason`, `note`, `blind`) VALUES (INET_ATON('" + _mysql.escape_string(ip) + "') & INET_ATON('"+_mysql.escape_string(netmask)+"'), INET_ATON('"+_mysql.escape_string(insnetmask)+"'), '" + _mysql.escape_string(where) + "', " + str(timestamp()) + ", " + until + ", '" + _mysql.escape_string(staff_account['username']) + "', '" + _mysql.escape_string(self.formdata['reason']) + "', '" + _mysql.escape_string(self.formdata['note']) + "', '"+blind+"')")
+
+ regenerateAccess()
+ if 'edit' in self.formdata.keys():
+ message = _('Ban successfully edited.')
+ action = 'Edited ban for ' + ip
+ else:
+ message = _('Ban successfully placed.')
+ action = 'Banned ' + ip
+ if until != '0':
+ action += ' until ' + formatTimestamp(until)
+ else:
+ action += ' permanently'
+ logAction(staff_account['username'], action)
+ template_filename = 'message.html'
+ else:
+ startvalues = {'where': [],
+ 'netmask': '255.255.255.255',
+ 'reason': '',
+ 'note': '',
+ 'message': '(GET OUT)',
+ 'seconds': '0',
+ 'blind': '1'}
+ edit_id = 0
+ if 'edit' in self.formdata.keys():
+ edit_id = self.formdata['edit']
+ ban = FetchOne("SELECT `id`, INET_NTOA(`ip`) AS 'ip', CASE WHEN `netmask` IS NULL THEN '255.255.255.255' ELSE INET_NTOA(`netmask`) END AS 'netmask', boards, added, until, staff, reason, note, blind FROM `bans` WHERE `id` = '" + _mysql.escape_string(edit_id) + "' ORDER BY `added` DESC")
+ if ban:
+ if ban['boards'] == '':
+ where = ''
+ else:
+ where = pickle.loads(ban['boards'])
+ if ban['until'] == '0':
+ until = 0
+ else:
+ until = int(ban['until']) - timestamp()
+ startvalues = {'where': where,
+ 'netmask': ban['netmask'],
+ 'reason': ban['reason'],
+ 'note': ban['note'],
+ 'seconds': str(until),
+ 'blind': ban['blind']
+ }
+ else:
+ edit_id = 0
+
+ template_filename = "bans.html"
+ template_values = {'mode': 1,
+ 'boards': boardlist(),
+ 'ip': ip,
+ 'startvalues': startvalues,
+ 'edit_id': edit_id}
+ elif path_split[2] == 'bans':
+ if not moderator:
+ return
+
+ action_taken = False
+ if len(path_split) > 4:
+ if path_split[3] == 'delete':
+ ip = FetchOne("SELECT INET_NTOA(`ip`) AS 'ip' FROM `bans` WHERE `id` = '" + _mysql.escape_string(path_split[4]) + "' LIMIT 1", 0)[0]
+ if ip != '':
+ # Delete ban
+ UpdateDb('DELETE FROM `bans` WHERE `id` = ' + _mysql.escape_string(path_split[4]) + ' LIMIT 1')
+ regenerateAccess()
+ message = _('Ban successfully deleted.')
+ template_filename = "message.html"
+ logAction(staff_account['username'], _('Deleted ban for %s') % ip)
+ else:
+ message = _('There was a problem while deleting that ban. It may have already been removed, or recently expired.')
+ template_filename = "message.html"
+
+ if not action_taken:
+ bans = FetchAll("SELECT `id`, INET_NTOA(`ip`) AS 'ip', CASE WHEN `netmask` IS NULL THEN '255.255.255.255' ELSE INET_NTOA(`netmask`) END AS 'netmask', boards, added, until, staff, reason, note, blind FROM `bans` ORDER BY `added` DESC")
+ if bans:
+ for ban in bans:
+ if ban['boards'] == '':
+ ban['boards'] = _('All boards')
+ else:
+ where = pickle.loads(ban['boards'])
+ if len(where) > 1:
+ ban['boards'] = '/' + '/, /'.join(where) + '/'
+ else:
+ ban['boards'] = '/' + where[0] + '/'
+ ban['added'] = formatTimestamp(ban['added'])
+ if ban['until'] == '0':
+ ban['until'] = _('Does not expire')
+ else:
+ ban['until'] = formatTimestamp(ban['until'])
+ if ban['blind'] == '1':
+ ban['blind'] = 'Sí'
+ else:
+ ban['blind'] = 'No'
+ template_filename = "bans.html"
+ template_values = {'mode': 0, 'bans': bans}
+ elif path_split[2] == 'changepassword':
+ form_submitted = False
+ try:
+ if self.formdata['oldpassword'] != '' and self.formdata['newpassword'] != '' and self.formdata['newpassword2'] != '':
+ form_submitted = True
+ except:
+ pass
+ if form_submitted:
+ if genPasswd(self.formdata['oldpassword']) == staff_account['password']:
+ if self.formdata['newpassword'] == self.formdata['newpassword2']:
+ UpdateDb('UPDATE `staff` SET `password` = \'' + genPasswd(self.formdata['newpassword']) + '\' WHERE `id` = ' + staff_account['id'] + ' LIMIT 1')
+ message = _('Password successfully changed. Please log out and log back in.')
+ template_filename = "message.html"
+ else:
+ message = _('Passwords did not match.')
+ template_filename = "message.html"
+ else:
+ message = _('Current password incorrect.')
+ template_filename = "message.html"
+ else:
+ template_filename = "changepassword.html"
+ template_values = {}
+ elif path_split[2] == 'board':
+ if not administrator:
+ return
+
+ if len(path_split) > 3:
+ board = setBoard(path_split[3])
+ form_submitted = False
+ try:
+ if self.formdata['name'] != '':
+ form_submitted = True
+ except:
+ pass
+ if form_submitted:
+ # Update board settings
+ board['name'] = self.formdata['name']
+ board['longname'] = self.formdata['longname']
+ board['subname'] = self.formdata['subname']
+ board['anonymous'] = self.formdata['anonymous']
+ board['subject'] = self.formdata['subject']
+ board['message'] = self.formdata['message']
+ if board['dir'] != 'anarkia':
+ board['board_type'] = self.formdata['type']
+ board['useid'] = self.formdata['useid']
+ board['slip'] = self.formdata['slip']
+ board['countrycode'] = self.formdata['countrycode']
+ if 'recyclebin' in self.formdata.keys():
+ board['recyclebin'] = '1'
+ else:
+ board['recyclebin'] = '0'
+ if 'disable_name' in self.formdata.keys():
+ board['disable_name'] = '1'
+ else:
+ board['disable_name'] = '0'
+ if 'disable_subject' in self.formdata.keys():
+ board['disable_subject'] = '1'
+ else:
+ board['disable_subject'] = '0'
+ if 'secret' in self.formdata.keys():
+ board['secret'] = '1'
+ else:
+ board['secret'] = '0'
+ if 'locked' in self.formdata.keys():
+ board['locked'] = '1'
+ else:
+ board['locked'] = '0'
+ board['postarea_desc'] = self.formdata['postarea_desc']
+ if 'allow_noimage' in self.formdata.keys():
+ board['allow_noimage'] = '1'
+ else:
+ board['allow_noimage'] = '0'
+ if 'allow_images' in self.formdata.keys():
+ board['allow_images'] = '1'
+ else:
+ board['allow_images'] = '0'
+ if 'allow_image_replies' in self.formdata.keys():
+ board['allow_image_replies'] = '1'
+ else:
+ board['allow_image_replies'] = '0'
+ if 'allow_spoilers' in self.formdata.keys():
+ board['allow_spoilers'] = '1'
+ else:
+ board['allow_spoilers'] = '0'
+ if 'allow_oekaki' in self.formdata.keys():
+ board['allow_oekaki'] = '1'
+ else:
+ board['allow_oekaki'] = '0'
+ if 'archive' in self.formdata.keys():
+ board['archive'] = '1'
+ else:
+ board['archive'] = '0'
+ board['postarea_extra'] = self.formdata['postarea_extra']
+ board['force_css'] = self.formdata['force_css']
+
+ # Update file types
+ UpdateDb("DELETE FROM `boards_filetypes` WHERE `boardid` = %s" % board['id'])
+ for filetype in filetypelist():
+ if 'filetype'+filetype['ext'] in self.formdata.keys():
+ UpdateDb("INSERT INTO `boards_filetypes` VALUES (%s, %s)" % (board['id'], filetype['id']))
+
+ try:
+ board['numthreads'] = int(self.formdata['numthreads'])
+ except:
+ raise UserError, _("Max threads shown must be numeric.")
+
+ try:
+ board['numcont'] = int(self.formdata['numcont'])
+ except:
+ raise UserError, _("Max replies shown must be numeric.")
+
+ try:
+ board['numline'] = int(self.formdata['numline'])
+ except:
+ raise UserError, _("Max lines shown must be numeric.")
+
+ try:
+ board['thumb_px'] = int(self.formdata['thumb_px'])
+ except:
+ raise UserError, _("Max thumb dimensions must be numeric.")
+
+ try:
+ board['maxsize'] = int(self.formdata['maxsize'])
+ except:
+ raise UserError, _("Max size must be numeric.")
+
+ try:
+ board['maxage'] = int(self.formdata['maxage'])
+ except:
+ raise UserError, _("Max age must be numeric.")
+
+ try:
+ board['maxinactive'] = int(self.formdata['maxinactive'])
+ except:
+ raise UserError, _("Max inactivity must be numeric.")
+
+ try:
+ board['threadsecs'] = int(self.formdata['threadsecs'])
+ except:
+ raise UserError, _("Time between new threads must be numeric.")
+
+ try:
+ board['postsecs'] = int(self.formdata['postsecs'])
+ except:
+ raise UserError, _("Time between replies must be numeric.")
+
+ updateBoardSettings()
+ message = _('Board options successfully updated.') + ' <a href="'+Settings.CGI_URL+'manage/rebuild/'+board['dir']+'">'+_('Rebuild')+'</a>'
+ template_filename = "message.html"
+ logAction(staff_account['username'], _('Updated options for /%s/') % board['dir'])
+ else:
+ template_filename = "boardoptions.html"
+ template_values = {'mode': 1, 'boardopts': board, 'filetypes': filetypelist(), 'supported_filetypes': board['filetypes_ext']}
+ else:
+ # List all boards
+ template_filename = "boardoptions.html"
+ template_values = {'mode': 0, 'boards': boardlist()}
+ elif path_split[2] == 'recyclebin':
+ if not administrator:
+ return
+
+ message = None
+ if len(path_split) > 5:
+ if path_split[4] == 'restore':
+ board = setBoard(path_split[5])
+
+ post = FetchOne('SELECT `parentid` FROM `posts` WHERE `boardid` = ' + board['id'] + ' AND `id` = \'' + _mysql.escape_string(path_split[6]) + '\' LIMIT 1')
+ if not post:
+ message = _('Unable to locate a post with that ID.') + '<br />'
+ template_filename = "message.html"
+ else:
+ UpdateDb('UPDATE `posts` SET `IS_DELETED` = 0 WHERE `boardid` = ' + board['id'] + ' AND `id` = \'' + _mysql.escape_string(path_split[6]) + '\' LIMIT 1')
+ if post['parentid'] != '0':
+ threadUpdated(post['parentid'])
+ else:
+ regenerateFrontPages()
+
+ message = _('Post successfully restored.')
+ logAction(staff_account['username'], _('Restored post %s') % ('/' + path_split[5] + '/' + path_split[6]))
+
+ if path_split[4] == 'delete':
+ board = setBoard(path_split[5])
+ post = FetchOne('SELECT `parentid` FROM `posts` WHERE `boardid` = ' + board['id'] + ' AND `id` = \'' + _mysql.escape_string(path_split[6]) + '\' LIMIT 1')
+ if not post:
+ message = _('Unable to locate a post with that ID.')
+ else:
+ deletePost(path_split[6], None)
+
+ if post['parentid'] != '0':
+ threadUpdated(post['parentid'])
+ else:
+ regenerateFrontPages()
+
+ message = _('Post successfully permadeleted.')
+ logAction(staff_account['username'], _('Permadeleted post %s') % ('/' + path_split[5] + '/' + path_split[6]))
+
+ # Delete more than 1 post
+ if 'deleteall' in self.formdata.keys():
+ return # TODO
+ deleted = 0
+ for key in self.formdata.keys():
+ if key[:2] == '!i':
+ dir = key[2:].split('/')[0] # Board where the post is
+ postid = key[2:].split('/')[1] # Post to delete
+
+ # Delete post start
+ post = FetchOne('SELECT `parentid`, `dir` FROM `posts` INNER JOIN `boards` ON posts.boardid = boards.id WHERE `dir` = \'' + _mysql.escape_string(dir) + '\' AND posts.id = \'' + _mysql.escape_string(postid) + '\' LIMIT 1')
+ if not post:
+ message = _('Unable to locate a post with that ID.')
+ else:
+ board = setBoard(dir)
+ deletePost(int(postid), None)
+ if post['parentid'] != '0':
+ threadUpdated(post['parentid'])
+ else:
+ regenerateFrontPages()
+ deleted += 1
+ # Delete post end
+
+ logAction(staff_account['username'], _('Permadeleted %s post(s).') % str(deleted))
+ message = _('Permadeleted %s post(s).') % str(deleted)
+
+ ## Start
+ import math
+ pagesize = float(Settings.RECYCLEBIN_POSTS_PER_PAGE)
+
+ try:
+ currentpage = int(path_split[3])
+ except:
+ currentpage = 0
+
+ skip = False
+ if 'type' in self.formdata.keys():
+ type = int(self.formdata["type"])
+ else:
+ type = 0
+
+ # Generate board list
+ boards = FetchAll('SELECT `name`, `dir` FROM `boards` ORDER BY `dir`')
+ for board in boards:
+ if 'board' in self.formdata.keys() and self.formdata['board'] == board['dir']:
+ board['checked'] = True
+ else:
+ board['checked'] = False
+
+ # Get type filter
+ if type != 0:
+ type_condition = "= " + str(type)
+ else:
+ type_condition = "!= 0"
+
+ # Table
+ if 'board' in self.formdata.keys() and self.formdata['board'] != 'all':
+ cboard = self.formdata['board']
+ posts = FetchAll("SELECT posts.id, posts.timestamp, timestamp_formatted, IS_DELETED, INET_NTOA(posts.ip) as ip, posts.message, dir, boardid FROM `posts` INNER JOIN `boards` ON boardid = boards.id WHERE `dir` = '%s' AND IS_DELETED %s ORDER BY `timestamp` DESC LIMIT %d, %d" % (_mysql.escape_string(self.formdata['board']), _mysql.escape_string(type_condition), currentpage*pagesize, pagesize))
+ try:
+ totals = FetchOne("SELECT COUNT(id) FROM `posts` WHERE IS_DELETED %s AND `boardid` = %s" % (_mysql.escape_string(type_condition), _mysql.escape_string(posts[0]['boardid'])), 0)
+ except:
+ skip = True
+ else:
+ cboard = 'all'
+ posts = FetchAll("SELECT posts.id, posts.timestamp, timestamp_formatted, IS_DELETED, posts.ip, posts.message, dir FROM `posts` INNER JOIN `boards` ON boardid = boards.id WHERE IS_DELETED %s ORDER BY `timestamp` DESC LIMIT %d, %d" % (_mysql.escape_string(type_condition), currentpage*pagesize, pagesize))
+ totals = FetchOne("SELECT COUNT(id) FROM `posts` WHERE IS_DELETED %s" % _mysql.escape_string(type_condition), 0)
+
+ template_filename = "recyclebin.html"
+ template_values = {'message': message,
+ 'type': type,
+ 'boards': boards,
+ 'skip': skip}
+
+ if not skip:
+ # Calculate number of pages
+ total = int(totals[0])
+ pages = int(math.ceil(total / pagesize))
+
+ # Create delete form
+ if 'board' in self.formdata.keys():
+ board = self.formdata['board']
+ else:
+ board = None
+
+ navigator = ''
+ if currentpage > 0:
+ navigator += '<a href="'+Settings.CGI_URL+'manage/recyclebin/'+str(currentpage-1)+'?type='+str(type)+'&amp;board='+cboard+'">&lt;</a> '
+ else:
+ navigator += '&lt; '
+
+ for i in range(pages):
+ if i != currentpage:
+ navigator += '<a href="'+Settings.CGI_URL+'manage/recyclebin/'+str(i)+'?type='+str(type)+'&amp;board='+cboard+'">'+str(i)+'</a> '
+ else:
+ navigator += str(i)+' '
+
+ if currentpage < (pages-1):
+ navigator += '<a href="'+Settings.CGI_URL+'manage/recyclebin/'+str(currentpage+1)+'?type='+str(type)+'&amp;board='+cboard+'">&gt;</a> '
+ else:
+ navigator += '&gt; '
+
+ template_values.update({'currentpage': currentpage,
+ 'curboard': board,
+ 'posts': posts,
+ 'navigator': navigator})
+ # End recyclebin
+ elif path_split[2] == 'lockboard':
+ if not administrator:
+ return
+
+ try:
+ board_dir = path_split[3]
+ except:
+ board_dir = ''
+
+ if board_dir == '':
+ template_filename = "lockboard.html"
+ template_values = {'boards': boardlist()}
+ elif path_split[2] == 'boardlock':
+ board = setBoard(path_split[3])
+ if int(board['locked']):
+ # Si esta cerrado... abrir
+ board['locked'] = 0
+ updateBoardSettings()
+ message = _('Board opened successfully.')
+ template_filename = "message.html"
+ else:
+ # Si esta abierta, cerrar
+ board['locked'] = 1
+ updateBoardSettings()
+ message = _('Board closed successfully.')
+ template_filename = "message.html"
+ elif path_split[2] == 'addboard':
+ if not administrator:
+ return
+
+ action_taken = False
+ board_dir = ''
+
+ try:
+ if self.formdata['name'] != '':
+ board_dir = self.formdata['dir']
+ except:
+ pass
+
+ if board_dir != '':
+ action_taken = True
+ board_exists = FetchOne('SELECT * FROM `boards` WHERE `dir` = \'' + _mysql.escape_string(board_dir) + '\' LIMIT 1')
+ if not board_exists:
+ os.mkdir(Settings.ROOT_DIR + board_dir)
+ os.mkdir(Settings.ROOT_DIR + board_dir + '/res')
+ if not os.path.exists(Settings.IMAGES_DIR + board_dir):
+ os.mkdir(Settings.IMAGES_DIR + board_dir)
+ os.mkdir(Settings.IMAGES_DIR + board_dir + '/src')
+ os.mkdir(Settings.IMAGES_DIR + board_dir + '/thumb')
+ os.mkdir(Settings.IMAGES_DIR + board_dir + '/mobile')
+ os.mkdir(Settings.IMAGES_DIR + board_dir + '/cat')
+ if os.path.exists(Settings.ROOT_DIR + board_dir) and os.path.isdir(Settings.ROOT_DIR + board_dir):
+ UpdateDb('INSERT INTO `boards` (`dir`, `name`) VALUES (\'' + _mysql.escape_string(board_dir) + '\', \'' + _mysql.escape_string(self.formdata['name']) + '\')')
+ board = setBoard(board_dir)
+ f = open(Settings.ROOT_DIR + board['dir'] + '/.htaccess', 'w')
+ try:
+ f.write('DirectoryIndex index.html')
+ finally:
+ f.close()
+ regenerateFrontPages()
+ message = _('Board added')
+ template_filename = "message.html"
+ logAction(staff_account['username'], _('Added board %s') % ('/' + board['dir'] + '/'))
+ else:
+ message = _('There was a problem while making the directories.')
+ template_filename = "message.html"
+ else:
+ message = _('There is already a board with that directory.')
+ template_filename = "message.html"
+
+ if not action_taken:
+ template_filename = "addboard.html"
+ template_values = {}
+ elif path_split[2] == 'trim':
+ if not administrator:
+ return
+ board = setBoard(path_split[3])
+ trimThreads()
+ self.output = "done trimming"
+ return
+ elif path_split[2] == 'setexpires':
+ board = setBoard(path_split[3])
+ parentid = int(path_split[4])
+ days = int(path_split[5])
+ t = time.time()
+
+ expires = int(t) + (days * 86400)
+ date_format = '%d/%m'
+ expires_formatted = datetime.datetime.fromtimestamp(expires).strftime(date_format)
+
+ sql = "UPDATE posts SET expires = timestamp + (%s * 86400), expires_formatted = FROM_UNIXTIME((timestamp + (%s * 86400)), '%s') WHERE boardid = %s AND id = %s" % (str(days), str(days), date_format, board["id"], str(parentid))
+ UpdateDb(sql)
+
+ self.output = "done " + sql
+ return
+ elif path_split[2] == 'fixflood':
+ if not administrator:
+ return
+ board = setBoard('zonavip')
+ threads = FetchAll("SELECT * FROM posts WHERE boardid = %s AND parentid = 0 AND subject LIKE 'querido mod%%'" % board['id'])
+ for thread in threads:
+ self.output += "%s<br>" % thread['id']
+ #deletePost(thread['id'], None)
+ return
+ elif path_split[2] == 'fixico':
+ board = setBoard(path_split[3])
+
+ threads = FetchAll("SELECT * FROM posts WHERE boardid = %s AND parentid = 0 AND message NOT LIKE '<img%%'" % board['id'])
+ for t in threads:
+ img_src = '<img src="%s" alt="ico" /><br />' % getRandomIco()
+ newmessage = img_src + t["message"]
+ #UpdateDb("UPDATE posts SET message = '%s' WHERE boardid = %s AND id = %s" % (_mysql.escape_string(newmessage), board['id'], t['id']))
+
+ self.output = repr(threads)
+ return
+ elif path_split[2] == 'fixkako':
+ board = setBoard(path_split[3])
+
+ threads = FetchAll('SELECT * FROM archive WHERE boardid = %s ORDER BY timestamp DESC' % board['id'])
+ for item in threads:
+ t = time.time()
+ self.output += item['timestamp'] + '<br />'
+ fname = Settings.ROOT_DIR + board["dir"] + "/kako/" + str(item["timestamp"]) + ".json"
+ if os.path.isfile(fname):
+ import json
+ with open(fname) as f:
+ thread = json.load(f)
+ thread['posts'] = [dict(zip(thread['keys'], row)) for row in thread['posts']]
+ template_fname = "txt_archive.html"
+
+ post_preview = cut_home_msg(thread['posts'][0]['message'], 0)
+ page = renderTemplate("txt_archive.html", {"threads": [thread], "preview": post_preview}, False)
+ with open(Settings.ROOT_DIR + board["dir"] + "/kako/" + str(thread['timestamp']) + ".html", "w") as f:
+ f.write(page)
+
+ self.output += 'done' + str(time.time() - t) + '<br />'
+ else:
+ self.output += 'El hilo no existe.<br />'
+ elif path_split[2] == 'fixexpires':
+ board = setBoard(path_split[3])
+
+ if int(board["maxage"]):
+ date_format = '%d/%m'
+ date_format_y = '%m/%Y'
+ if int(board["maxage"]) >= 365:
+ date_format = date_format_y
+ sql = "UPDATE posts SET expires = timestamp + (%s * 86400), expires_formatted = FROM_UNIXTIME((timestamp + (%s * 86400)), '%s') WHERE boardid = %s AND parentid = 0" % (board["maxage"], board["maxage"], date_format, board["id"])
+ UpdateDb(sql)
+
+ alert_time = int(round(int(board['maxage']) * Settings.MAX_AGE_ALERT))
+ sql = "UPDATE posts SET expires_alert = CASE WHEN UNIX_TIMESTAMP() > (expires - %d*86400) THEN 1 ELSE 0 END WHERE boardid = %s AND parentid = 0" % (alert_time, board["id"])
+ UpdateDb(sql)
+ else:
+ sql = "UPDATE posts SET expires = 0, expires_formatted = '', expires_alert = 0 WHERE boardid = %s AND parentid = 0" % (board["id"])
+ UpdateDb(sql)
+
+ self.output = "done"
+ return
+ elif path_split[2] == 'fixid':
+ board = setBoard(path_split[3])
+ posts = FetchAll('SELECT * FROM `posts` WHERE `boardid` = %s' % board['id'])
+ self.output = "total: %d<br />" % len(posts)
+ for post in posts:
+ new_timestamp_formatted = formatTimestamp(post['timestamp'])
+ tim = 0
+ if board["useid"] != '0':
+ new_timestamp_formatted += ' ID:' + iphash(post['ip'], '', tim, '1', False, False, False, '0')
+ self.output += "%s - %s <br />" % (post['id'], new_timestamp_formatted)
+ query = "UPDATE `posts` SET timestamp_formatted = '%s' WHERE boardid = '%s' AND id = '%s'" % (new_timestamp_formatted, board['id'], post['id'])
+ UpdateDb(query)
+ return
+ elif path_split[2] == 'fixname':
+ board = setBoard(path_split[3])
+ #posts = FetchAll('SELECT * FROM `posts` WHERE `boardid` = %s' % board['id'])
+ posts = FetchAll('SELECT * FROM `posts` WHERE `name` LIKE \'%s\'' % '%%')
+ new_name = board['anonymous']
+ self.output = new_name + "<br />"
+ for post in posts:
+ self.output += "%s<br />" % (post['id'])
+ query = "UPDATE `posts` SET `name` = '%s' WHERE boardid = '%s' AND id = '%s'" % (new_name, board['id'], post['id'])
+ UpdateDb(query)
+ return
+ elif path_split[2] == 'setsub':
+ board = setBoard(path_split[3])
+ thread = FetchOne('SELECT * FROM `posts` WHERE `parentid` = 0 AND `boardid` = %s' % board['id'])
+ subject = str(path_split[4])
+ self.output = subject + "->" + thread['id'] + "<br />"
+ query = "UPDATE `posts` SET `subject` = '%s' WHERE boardid = '%s' AND id = '%s'" % (subject, board['id'], thread['id'])
+ UpdateDb(query)
+ return
+ elif path_split[2] == 'fixlength':
+ board = setBoard(path_split[3])
+ threads = FetchAll('SELECT * FROM `posts` WHERE parentid = 0 AND `boardid` = %s' % board['id'])
+ for t in threads:
+ length = threadNumReplies(t['id'])
+ UpdateDb('UPDATE posts SET length = %d WHERE boardid = %s AND id = %s' % (length, board['id'], t['id']))
+
+ self.output='done'
+ return
+ elif path_split[2] == 'archive':
+ t = time.time()
+ board = setBoard(path_split[3])
+ postid = int(path_split[4])
+ archiveThread(postid)
+ self.output = "todo ok %s" % str(time.time() - t)
+ elif path_split[2] == 'filters':
+ action_taken = False
+ if len(path_split) > 3 and path_split[3] == 'add':
+ if "add" in self.formdata.keys():
+ edit_id = 0
+ if 'edit' in self.formdata.keys():
+ edit_id = int(self.formdata['edit'])
+
+ # We decide what type of filter it is.
+ # 0: Word / 1: Name/Trip
+ filter_type = int(self.formdata["type"])
+ filter_action = int(self.formdata["action"])
+ filter_from = ''
+ filter_tripcode = ''
+
+ # I don't like pickles... oh well.
+ where = ''
+ if 'board_all' not in self.formdata.keys():
+ where = []
+ boards = FetchAll('SELECT `dir` FROM `boards`')
+ for board in boards:
+ keyname = 'board_' + board['dir']
+ if keyname in self.formdata.keys():
+ if self.formdata[keyname] == "1":
+ where.append(board['dir'])
+ if len(where) > 0:
+ where = _mysql.escape_string(pickle.dumps(where))
+ else:
+ self.error(_("You must select what board the filter will affect"))
+ return
+
+ if filter_type == 0:
+ # Word filter
+ if len(self.formdata["word"]) > 0:
+ filter_from = _mysql.escape_string(cgi.escape(self.formdata["word"]))
+ else:
+ self.error(_("You must enter a word."))
+ return
+ elif filter_type == 1:
+ # Name/trip filter
+ can_add = False
+ if len(self.formdata["name"]) > 0:
+ filter_from = _mysql.escape_string(self.formdata["name"])
+ can_add = True
+ if len(self.formdata["trip"]) > 0:
+ filter_tripcode = _mysql.escape_string(self.formdata["trip"])
+ can_add = True
+ if not can_add:
+ self.error(_("You must enter a name and/or a tripcode."))
+ return
+
+ # Action
+ sql_query = ''
+ filter_reason = ''
+ if len(self.formdata["reason"]) > 0:
+ filter_reason = _mysql.escape_string(self.formdata["reason"])
+ if filter_action == 0:
+ # Cancel post
+ sql_query = "INSERT INTO `filters` (`id`, `boards`, `type`, `action`, `from`, `from_trip`, `reason`, `added`, `staff`) VALUES (%d, '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s')" % \
+ (edit_id, where, str(filter_type), str(filter_action), filter_from, filter_tripcode, filter_reason, str(timestamp()), _mysql.escape_string(staff_account['username']))
+ elif filter_action == 1:
+ # Change to
+ if len(self.formdata["changeto"]) > 0:
+ filter_to = _mysql.escape_string(self.formdata["changeto"])
+ sql_query = "INSERT INTO `filters` (`id`, `boards`, `type`, `action`, `from`, `from_trip`, `reason`, `to`, `added`, `staff`) VALUES (%d, '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s')" % \
+ (edit_id, where, str(filter_type), str(filter_action), filter_from, filter_tripcode, filter_reason, filter_to, str(timestamp()), _mysql.escape_string(staff_account['username']))
+ else:
+ self.error(_("You must enter a word to change to."))
+ return
+ elif filter_action == 2:
+ # Ban
+ filter_seconds = '0'
+ if len(self.formdata["seconds"]) > 0:
+ filter_seconds = _mysql.escape_string(self.formdata["seconds"])
+ if "blind" in self.formdata.keys() and self.formdata["blind"] == '1':
+ filter_blind = '1'
+ else:
+ filter_blind = '2'
+
+ sql_query = "INSERT INTO `filters` (`id`, `boards`, `type`, `action`, `from`, `from_trip`, `reason`, `seconds`, `blind`, `added`, `staff`) VALUES (%d, '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s')" % \
+ (edit_id, where, str(filter_type), str(filter_action), filter_from, filter_tripcode, filter_reason, filter_seconds, filter_blind, str(timestamp()), _mysql.escape_string(staff_account['username']))
+ elif filter_action == 3:
+ # Redirect URL
+ if len(self.formdata['redirect_url']) > 0:
+ redirect_url = _mysql.escape_string(self.formdata['redirect_url'])
+ redirect_time = 0
+ try:
+ redirect_time = int(self.formdata['redirect_time'])
+ except:
+ pass
+ else:
+ self.error(_("You must enter a URL to redirect to."))
+ return
+
+ sql_query = "INSERT INTO `filters` (`id`, `boards`, `type`, `action`, `from`, `from_trip`, `reason`, `redirect_url`, `redirect_time`, `added`, `staff`) VALUES (%d, '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s')" % \
+ (edit_id, where, str(filter_type), str(filter_action), filter_from, filter_tripcode, filter_reason, redirect_url, str(redirect_time), str(timestamp()), _mysql.escape_string(staff_account['username']))
+ # DO QUERY!
+ if edit_id > 0:
+ UpdateDb("DELETE FROM `filters` WHERE `id` = %s" % str(edit_id))
+ UpdateDb(sql_query)
+ message = 'Filter edited.'
+ else:
+ filt = FetchOne("SELECT `id` FROM `filters` WHERE `boards` = '%s' AND `type` = '%s' AND `from` = '%s'" % (where, str(filter_type), filter_from))
+ if not filt:
+ UpdateDb(sql_query)
+ message = 'Filter added.'
+ else:
+ message = 'This filter already exists here:' + ' <a href="'+Settings.CGI_URL+'manage/filters/add?edit='+filt['id']+'">edit</a>'
+ action_taken = True
+ template_filename = "message.html"
+ else:
+ # Create add form
+ edit_id = 0
+ if 'edit' in self.formdata.keys() and int(self.formdata['edit']) > 0:
+ # Load values
+ edit_id = int(self.formdata['edit'])
+ filt = FetchOne("SELECT * FROM `filters` WHERE `id` = %s LIMIT 1" % str(edit_id))
+ if filt['boards'] == '':
+ where = ''
+ else:
+ where = pickle.loads(filt['boards'])
+ startvalues = {'type': filt['type'],
+ 'trip': filt['from_trip'],
+ 'where': where,
+ 'action': filt['action'],
+ 'changeto': cgi.escape(filt['to'], True),
+ 'reason': filt['reason'],
+ 'seconds': filt['seconds'],
+ 'blind': filt['blind'],
+ 'redirect_url': filt['redirect_url'],
+ 'redirect_time': filt['redirect_time'],}
+ if filt['type'] == '1':
+ startvalues['name'] = filt['from']
+ startvalues['word'] = ''
+ else:
+ startvalues['name'] = ''
+ startvalues['word'] = filt['from']
+ else:
+ startvalues = {'type': '0',
+ 'word': '',
+ 'name': '',
+ 'trip': '',
+ 'where': [],
+ 'action': '0',
+ 'changeto': '',
+ 'reason': _('Forbidden word'),
+ 'seconds': '0',
+ 'blind': '1',
+ 'redirect_url': 'http://',
+ 'redirect_time': '5'}
+
+ if edit_id > 0:
+ submit = "Editar Filtro"
+ else:
+ submit = "Agregar filtro"
+
+ action_taken = True
+ template_filename = "filters.html"
+ template_values = {'mode': 1,
+ 'edit_id': edit_id,
+ 'boards': boardlist(),
+ 'startvalues': startvalues,
+ 'submit': submit}
+ elif len(path_split) > 4 and path_split[3] == 'delete':
+ delid = int(path_split[4])
+ UpdateDb("DELETE FROM `filters` WHERE id = '%s' LIMIT 1" % str(delid))
+ message = _('Deleted filter %s.') % str(delid)
+ template_filename = "message.html"
+ action_taken = True
+
+ if not action_taken:
+ filters = FetchAll("SELECT * FROM `filters` ORDER BY `added` DESC")
+ for filter in filters:
+ if filter['boards'] == '':
+ filter['boards'] = _('All boards')
+ else:
+ where = pickle.loads(filter['boards'])
+ if len(where) > 1:
+ filter['boards'] = '/' + '/, /'.join(where) + '/'
+ else:
+ filter['boards'] = '/' + where[0] + '/'
+ if filter['type'] == '0':
+ filter['type_formatted'] = _('Word:') + ' <b>' + cgi.escape(filter['from']) + '</b>'
+ elif filter['type'] == '1':
+ filter['type_formatted'] = _('Name/Tripcode:')+' '
+ if filter['from'] != '':
+ filter['type_formatted'] += '<b class="name">' + filter['from'] + '</b>'
+ if filter['from_trip'] != '':
+ filter['type_formatted'] += '<span class="trip">' + filter['from_trip'] + '</span>'
+ else:
+ filter['type_formatted'] = '?'
+ if filter['action'] == '0':
+ filter ['action_formatted'] = _('Abort post')
+ elif filter['action'] == '1':
+ filter ['action_formatted'] = _('Change to:') + ' <b>' + cgi.escape(filter['to']) + '</b>'
+ elif filter['action'] == '2':
+ if filter['blind'] == '1':
+ blind = _('Yes')
+ else:
+ blind = _('No')
+ filter ['action_formatted'] = _('Autoban:') + '<br />' + \
+ (_('Length:')+' <i>%s</i><br />'+_('Blind:')+' <i>%s</i>') % (filter['seconds'], blind)
+ elif filter['action'] == '3':
+ filter ['action_formatted'] = (_('Redirect to:')+' %s ('+_('in %s secs')+')') % (filter['redirect_url'], filter['redirect_time'])
+ else:
+ filter ['action_formatted'] = '?'
+ filter['added'] = formatTimestamp(filter['added'])
+
+ template_filename = "filters.html"
+ template_values = {'mode': 0, 'filters': filters}
+ elif path_split[2] == 'logs':
+ if staff_account['rights'] != '0' and staff_account['rights'] != '2':
+ return
+
+ logs = FetchAll('SELECT * FROM `logs` ORDER BY `timestamp` DESC')
+ for log in logs:
+ log['timestamp_formatted'] = formatTimestamp(log['timestamp'])
+ template_filename = "logs.html"
+ template_values = {'logs': logs}
+ elif path_split[2] == 'logout':
+ message = _('Logging out...') + '<meta http-equiv="refresh" content="0;url=' + Settings.CGI_URL + 'manage" />'
+ setCookie(self, 'weabot_manage', '', domain='THIS')
+ setCookie(self, 'weabot_staff', '')
+ template_filename = "message.html"
+ elif path_split[2] == 'quotes':
+ # Quotes for the post screen
+ if "save" in self.formdata.keys():
+ try:
+ f = open('quotes.conf', 'w')
+ f.write(self.formdata["data"])
+ f.close()
+ message = 'Datos guardados.'
+ template_filename = "message.html"
+ except:
+ message = 'Error al guardar datos.'
+ template_filename = "message.html"
+ try:
+ f = open('quotes.conf', 'r')
+ data = cgi.escape(f.read(1048576), True)
+ f.close()
+ template_filename = "quotes.html"
+ template_values = {'data': data}
+ except:
+ message = 'Error al leer datos.'
+ template_filename = 'message.html'
+ elif path_split[2] == 'recent_images':
+ try:
+ if int(self.formdata['images']) > 100:
+ images = '100'
+ else:
+ images = self.formdata['images']
+ posts = FetchAll('SELECT * FROM `posts` INNER JOIN `boards` ON boardid = boards.id WHERE CHAR_LENGTH(`thumb`) > 0 ORDER BY `timestamp` DESC LIMIT ' + _mysql.escape_string(images))
+ except:
+ posts = FetchAll('SELECT * FROM `posts` INNER JOIN `boards` ON boardid = boards.id WHERE CHAR_LENGTH(`thumb`) > 0 ORDER BY `timestamp` DESC LIMIT 10')
+ template_filename = "recent_images.html"
+ template_values = {'posts': posts}
+ elif path_split[2] == 'news':
+ if not administrator:
+ return
+
+ type = 1
+ if 'type' in self.formdata:
+ type = int(self.formdata['type'])
+
+ if type > 2:
+ raise UserError, "Tipo no soportado"
+
+ # canal del home
+ if len(path_split) > 3:
+ if path_split[3] == 'add':
+ t = datetime.datetime.now()
+
+ # Insertar el nuevo post
+ title = ''
+ message = self.formdata["message"].replace("\n", "<br />")
+
+ # Titulo
+ if 'title' in self.formdata:
+ title = self.formdata["title"]
+
+ # Post anonimo
+ if 'anonymous' in self.formdata.keys() and self.formdata['anonymous'] == '1':
+ to_name = "Staff ★"
+ else:
+ to_name = "%s ★" % staff_account['username']
+ timestamp_formatted = formatDate(t)
+ if type > 0:
+ timestamp_formatted = re.sub(r"\(.+", "", timestamp_formatted)
+ else:
+ timestamp_formatted = re.sub(r"\(...\)", " ", timestamp_formatted)
+
+ UpdateDb("INSERT INTO `news` (type, staffid, staff_name, title, message, name, timestamp, timestamp_formatted) VALUES (%d, '%s', '%s', '%s', '%s', '%s', '%d', '%s')" % (type, staff_account['id'], staff_account['username'], _mysql.escape_string(title), _mysql.escape_string(message), to_name, timestamp(t), timestamp_formatted))
+
+ regenerateNews()
+ regenerateHome()
+ message = _("Added successfully.")
+ template_filename = "message.html"
+ if path_split[3] == 'delete':
+ # Eliminar un post
+ id = int(path_split[4])
+ UpdateDb("DELETE FROM `news` WHERE id = %d AND type = %d" % (id, type))
+ regenerateNews()
+ regenerateHome()
+ message = _("Deleted successfully.")
+ template_filename = "message.html"
+ else:
+ posts = FetchAll("SELECT * FROM `news` WHERE type = %d ORDER BY `timestamp` DESC" % type)
+ template_filename = "news.html"
+ template_values = {'action': type, 'posts': posts}
+ elif path_split[2] == 'newschannel':
+ #if not administrator:
+ # return
+
+ if len(path_split) > 3:
+ if path_split[3] == 'add':
+ t = datetime.datetime.now()
+ # Delete old posts
+ #posts = FetchAll("SELECT `id` FROM `news` WHERE `type` = '1' ORDER BY `timestamp` DESC LIMIT "+str(Settings.MODNEWS_MAX_POSTS)+",30")
+ #for post in posts:
+ # UpdateDb("DELETE FROM `news` WHERE id = " + post['id'] + " AND `type` = '0'")
+
+ # Insert new post
+ message = ''
+ try:
+ # Cut long lines
+ message = self.formdata["message"]
+ message = clickableURLs(cgi.escape(message).rstrip()[0:8000])
+ message = onlyAllowedHTML(message)
+ if Settings.USE_MARKDOWN:
+ message = markdown(message)
+ if not Settings.USE_MARKDOWN:
+ message = message.replace("\n", "<br />")
+ except:
+ pass
+
+ # If it's preferred to remain anonymous...
+ if 'anonymous' in self.formdata.keys() and self.formdata['anonymous'] == '1':
+ to_name = "Staff ★"
+ else:
+ to_name = "%s ★" % staff_account['username']
+ timestamp_formatted = formatDate(t)
+
+ UpdateDb("INSERT INTO `news` (type, staffid, staff_name, title, message, name, timestamp, timestamp_formatted) VALUES ('0', '%s', '%s', '%s', '%s', '%s', '%d', '%s')" % (staff_account['id'], staff_account['username'], _mysql.escape_string(self.formdata['title']), _mysql.escape_string(message), to_name, timestamp(t), timestamp_formatted))
+
+ message = _("Added successfully.")
+ template_filename = "message.html"
+ if path_split[3] == 'delete':
+ if not administrator:
+ # We check that if he's not admin, he shouldn't be able to delete other people's posts
+ post = FetchOne("SELECT `staffid` FROM `news` WHERE id = '"+_mysql.escape_string(path_split[4])+"' AND type = '0'")
+ if post['staffid'] != staff_account['id']:
+ self.error(_('That post is not yours.'))
+ return
+
+ # Delete!
+ UpdateDb("DELETE FROM `news` WHERE id = '" + _mysql.escape_string(path_split[4]) + "' AND type = '0'")
+ message = _("Deleted successfully.")
+ template_filename = "message.html"
+ else:
+ # If he's not admin, show only his own posts
+ if administrator:
+ posts = FetchAll("SELECT * FROM `news` WHERE type = '0' ORDER BY `timestamp` DESC")
+ else:
+ posts = FetchAll("SELECT * FROM `news` WHERE staffid = '"+staff_account['id']+"' AND type = '0' ORDER BY `timestamp` DESC")
+
+ template_filename = "news.html"
+ template_values = {'action': 'newschannel', 'posts': posts}
+ elif path_split[2] == 'reports':
+ if not moderator:
+ return
+
+ message = None
+ import math
+ pagesize = float(Settings.REPORTS_PER_PAGE)
+ totals = FetchOne("SELECT COUNT(id) FROM `reports`")
+ total = int(totals['COUNT(id)'])
+ pages = int(math.ceil(total / pagesize))
+
+ try:
+ currentpage = int(path_split[3])
+ except:
+ currentpage = 0
+
+ if len(path_split) > 4:
+ if path_split[4] == 'ignore':
+ # Delete report
+ UpdateDb("DELETE FROM `reports` WHERE `id` = '"+_mysql.escape_string(path_split[5])+"'")
+ message = _('Report %s ignored.') % path_split[5]
+ if 'ignore' in self.formdata.keys():
+ ignored = 0
+ if 'board' in self.formdata.keys() and self.formdata['board'] != 'all':
+ reports = FetchAll("SELECT `id` FROM `reports` WHERE `board` = '%s' ORDER BY `timestamp` DESC LIMIT %d, %d" % (_mysql.escape_string(self.formdata['board']), currentpage*pagesize, pagesize))
+ else:
+ reports = FetchAll("SELECT `id` FROM `reports` ORDER BY `timestamp` DESC LIMIT %d, %d" % (currentpage*pagesize, pagesize))
+
+ for report in reports:
+ keyname = 'i' + report['id']
+ if keyname in self.formdata.keys():
+ # Ignore here
+ UpdateDb("DELETE FROM `reports` WHERE `id` = '"+_mysql.escape_string(report['id'])+"'")
+ ignored += 1
+
+ message = _('Ignored %s report(s).') % str(ignored)
+
+ # Generate board list
+ boards = FetchAll('SELECT `name`, `dir` FROM `boards` ORDER BY `dir`')
+ for board in boards:
+ if 'board' in self.formdata.keys() and self.formdata['board'] == board['dir']:
+ board['checked'] = True
+ else:
+ board['checked'] = False
+
+ # Tabla
+ if 'board' in self.formdata.keys() and self.formdata['board'] != 'all':
+ reports = FetchAll("SELECT id, timestamp, timestamp_formatted, postid, parentid, link, board, INET_NTOA(ip) AS ip, reason, reporterip FROM `reports` WHERE `board` = '%s' ORDER BY `timestamp` DESC LIMIT %d, %d" % (_mysql.escape_string(self.formdata['board']), currentpage*pagesize, pagesize))
+ else:
+ reports = FetchAll("SELECT id, timestamp, timestamp_formatted, postid, parentid, link, board, INET_NTOA(ip) AS ip, reason, reporterip FROM `reports` ORDER BY `timestamp` DESC LIMIT %d, %d" % (currentpage*pagesize, pagesize))
+
+ if 'board' in self.formdata.keys():
+ curboard = self.formdata['board']
+ else:
+ curboard = None
+
+ #for report in reports:
+ # if report['parentid'] == '0':
+ # report['link'] = Settings.BOARDS_URL + report['board'] + '/res/' + report['postid'] + '.html#' + report['postid']
+ # else:
+ # report['link'] = Settings.BOARDS_URL + report['board'] + '/res/' + report['parentid'] + '.html#' + report['postid']
+
+ navigator = ''
+ if currentpage > 0:
+ navigator += '<a href="'+Settings.CGI_URL+'manage/reports/'+str(currentpage-1)+'">&lt;</a> '
+ else:
+ navigator += '&lt; '
+
+ for i in range(pages):
+ if i != currentpage:
+ navigator += '<a href="'+Settings.CGI_URL+'manage/reports/'+str(i)+'">'+str(i)+'</a> '
+ else:
+ navigator += str(i)+' '
+
+ if currentpage < (pages-1):
+ navigator += '<a href="'+Settings.CGI_URL+'manage/reports/'+str(currentpage+1)+'">&gt;</a> '
+ else:
+ navigator += '&gt; '
+
+ template_filename = "reports.html"
+ template_values = {'message': message,
+ 'boards': boards,
+ 'reports': reports,
+ 'currentpage': currentpage,
+ 'curboard': curboard,
+ 'navigator': navigator}
+ # Show by IP
+ elif path_split[2] == 'ipshow':
+ if not moderator:
+ return
+
+ if 'ip' in self.formdata.keys():
+ # If an IP was given...
+ if self.formdata['ip'] != '':
+ formatted_ip = str(inet_aton(self.formdata['ip']))
+ posts = FetchAll("SELECT posts.*, boards.dir, boards.board_type, boards.subject AS default_subject FROM `posts` JOIN `boards` ON boards.id = posts.boardid WHERE ip = '%s' ORDER BY posts.timestamp DESC" % _mysql.escape_string(formatted_ip))
+ ip = self.formdata['ip']
+ template_filename = "ipshow.html"
+ template_values = {"mode": 1, "ip": ip, "host": getHost(ip), "country": getCountry(ip), "tor": addressIsTor(ip), "posts": posts}
+ logAction(staff_account['username'], "ipshow on {}".format(ip))
+ else:
+ # Generate form
+ template_filename = "ipshow.html"
+ template_values = {"mode": 0}
+ elif path_split[2] == 'ipdelete':
+ if not moderator:
+ return
+
+ # Delete by IP
+ if 'ip' in self.formdata.keys():
+ # If an IP was given...
+ if self.formdata['ip'] != '':
+ where = []
+ if 'board_all' not in self.formdata.keys():
+ # If he chose boards separately, add them to a list
+ boards = FetchAll('SELECT `id`, `dir` FROM `boards`')
+ for board in boards:
+ keyname = 'board_' + board['dir']
+ if keyname in self.formdata.keys():
+ if self.formdata[keyname] == "1":
+ where.append(board)
+ else:
+ # If all boards were selected="selected", all them all to the list
+ where = FetchAll('SELECT `id`, `dir` FROM `boards`')
+
+ # If no board was chosen
+ if len(where) <= 0:
+ self.error(_("Select a board first."))
+ return
+
+ deletedPostsTotal = 0
+ ip = inet_aton(self.formdata['ip'])
+ deletedPosts = 0
+ for theboard in where:
+ board = setBoard(theboard['dir'])
+ isDeletedOP = False
+
+ # delete all starting posts first
+ op_posts = FetchAll("SELECT `id`, `message` FROM posts WHERE parentid = 0 AND boardid = '" + board['id'] + "' AND ip = " + str(ip))
+ for post in op_posts:
+ deletePost(post['id'], None)
+
+ deletedPosts += 1
+ deletedPostsTotal += 1
+
+ replies = FetchAll("SELECT `id`, `message`, `parentid` FROM posts WHERE parentid != 0 AND boardid = '" + board['id'] + "' AND ip = " + str(ip))
+ for post in replies:
+ deletePost(post['id'], None, '2')
+
+ deletedPosts += 1
+ deletedPostsTotal += 1
+
+ regenerateHome()
+
+ if deletedPosts > 0:
+ message = '%(posts)s post(s) were deleted from %(board)s.' % {'posts': str(deletedPosts), 'board': '/' + board['dir'] + '/'}
+ template_filename = "message.html"
+ #logAction(staff_account['username'], '%(posts)s post(s) were deleted from %(board)s. IP: %(ip)s' % \
+ # {'posts': str(deletedPosts),
+ # 'board': '/' + board['dir'] + '/',
+ # 'ip': self.formdata['ip']})
+ else:
+ self.error(_("Please enter an IP first."))
+ return
+
+ message = 'In total %(posts)s from IP %(ip)s were deleted.' % {'posts': str(deletedPosts), 'ip': self.formdata['ip']}
+ template_filename = "message.html"
+ else:
+ # Generate form...
+ template_filename = "ipdelete.html"
+ template_values = {'boards': boardlist()}
+ elif path_split[2] == 'search':
+ if not administrator:
+ return
+ search_logs = FetchAll('SELECT `id`,`timestamp`,`keyword`,`ita`,INET_NTOA(`ip`) AS `ip`,`res` FROM `search_log` ORDER BY `timestamp` DESC LIMIT 250')
+ for log in search_logs:
+ #log['ip'] = str(inet_ntoa(log['ip']))
+ log['timestamp_formatted'] = formatTimestamp(log['timestamp'])
+ if log['keyword'].startswith('k '):
+ log['keyword'] = log['keyword'][2:]
+ log['archive'] = True
+ else:
+ log['archive'] = False
+ template_filename = "search.html"
+ template_values = {'search': search_logs}
+ else:
+ # Main page.
+ reports = FetchOne("SELECT COUNT(1) FROM `reports`", 0)[0]
+ posts = FetchAll("SELECT * FROM `news` WHERE type = '0' ORDER BY `timestamp` DESC")
+
+ template_filename = "manage.html"
+ template_values = {'reports': reports, 'posts': posts}
+
+ if not skiptemplate:
+ try:
+ if template_filename == 'message.html':
+ template_values = {'message': message}
+ except:
+ template_filename = 'message.html'
+ template_values = {'message': '???'}
+
+ template_values.update({
+ 'title': 'Manage',
+ 'validated': validated,
+ 'page': page,
+ })
+
+ if validated:
+ template_values.update({
+ 'username': staff_account['username'],
+ 'site_title': Settings.SITE_TITLE,
+ 'rights': staff_account['rights'],
+ 'administrator': administrator,
+ 'added': formatTimestamp(staff_account['added']),
+ })
+
+ self.output += renderTemplate("manage/" + template_filename, template_values)
+
+def logAction(staff, action):
+ InsertDb("INSERT INTO `logs` (`timestamp`, `staff`, `action`) VALUES (" + str(timestamp()) + ", '" + _mysql.escape_string(staff) + "\', \'" + _mysql.escape_string(action) + "\')")
+
+def genPasswd(string):
+ return getMD5(string + Settings.SECRET)
+
+def boardlist():
+ boards = FetchAll('SELECT * FROM `boards` ORDER BY `board_type`, `dir`')
+ return boards
+
+def filetypelist():
+ filetypes = FetchAll('SELECT * FROM `filetypes` ORDER BY `ext` ASC')
+ return filetypes
diff --git a/cgi/markdown.py b/cgi/markdown.py
new file mode 100644
index 0000000..3ebfaab
--- /dev/null
+++ b/cgi/markdown.py
@@ -0,0 +1,2044 @@
+#!/usr/bin/env python
+# Copyright (c) 2007-2008 ActiveState Corp.
+# License: MIT (http://www.opensource.org/licenses/mit-license.php)
+
+r"""A fast and complete Python implementation of Markdown.
+
+[from http://daringfireball.net/projects/markdown/]
+> Markdown is a text-to-HTML filter; it translates an easy-to-read /
+> easy-to-write structured text format into HTML. Markdown's text
+> format is most similar to that of plain text email, and supports
+> features such as headers, *emphasis*, code blocks, blockquotes, and
+> links.
+>
+> Markdown's syntax is designed not as a generic markup language, but
+> specifically to serve as a front-end to (X)HTML. You can use span-level
+> HTML tags anywhere in a Markdown document, and you can use block level
+> HTML tags (like <div> and <table> as well).
+
+Module usage:
+
+ >>> import markdown2
+ >>> markdown2.markdown("*boo!*") # or use `html = markdown_path(PATH)`
+ u'<p><em>boo!</em></p>\n'
+
+ >>> markdowner = Markdown()
+ >>> markdowner.convert("*boo!*")
+ u'<p><em>boo!</em></p>\n'
+ >>> markdowner.convert("**boom!**")
+ u'<p><strong>boom!</strong></p>\n'
+
+This implementation of Markdown implements the full "core" syntax plus a
+number of extras (e.g., code syntax coloring, footnotes) as described on
+<http://code.google.com/p/python-markdown2/wiki/Extras>.
+"""
+
+cmdln_desc = """A fast and complete Python implementation of Markdown, a
+text-to-HTML conversion tool for web writers.
+
+Supported extras (see -x|--extras option below):
+* code-friendly: Disable _ and __ for em and strong.
+* code-color: Pygments-based syntax coloring of <code> sections.
+* cuddled-lists: Allow lists to be cuddled to the preceding paragraph.
+* footnotes: Support footnotes as in use on daringfireball.net and
+ implemented in other Markdown processors (tho not in Markdown.pl v1.0.1).
+* html-classes: Takes a dict mapping html tag names (lowercase) to a
+ string to use for a "class" tag attribute. Currently only supports
+ "pre" and "code" tags. Add an issue if you require this for other tags.
+* pyshell: Treats unindented Python interactive shell sessions as <code>
+ blocks.
+* link-patterns: Auto-link given regex patterns in text (e.g. bug number
+ references, revision number references).
+* xml: Passes one-liner processing instructions and namespaced XML tags.
+"""
+
+# Dev Notes:
+# - There is already a Python markdown processor
+# (http://www.freewisdom.org/projects/python-markdown/).
+# - Python's regex syntax doesn't have '\z', so I'm using '\Z'. I'm
+# not yet sure if there implications with this. Compare 'pydoc sre'
+# and 'perldoc perlre'.
+
+__version_info__ = (1, 0, 1, 17) # first three nums match Markdown.pl
+__version__ = '1.0.1.17'
+__author__ = "Trent Mick"
+
+import os
+import sys
+from pprint import pprint
+import re
+import logging
+try:
+ from hashlib import md5
+except ImportError:
+ from md5 import md5
+import optparse
+from random import random, randint
+import codecs
+from urllib import quote
+
+
+
+#---- Python version compat
+
+if sys.version_info[:2] < (2,4):
+ from sets import Set as set
+ def reversed(sequence):
+ for i in sequence[::-1]:
+ yield i
+ def _unicode_decode(s, encoding, errors='xmlcharrefreplace'):
+ return unicode(s, encoding, errors)
+else:
+ def _unicode_decode(s, encoding, errors='strict'):
+ return s.decode(encoding, errors)
+
+
+#---- globals
+
+DEBUG = False
+log = logging.getLogger("markdown")
+
+DEFAULT_TAB_WIDTH = 4
+
+
+try:
+ import uuid
+except ImportError:
+ SECRET_SALT = str(randint(0, 1000000))
+else:
+ SECRET_SALT = str(uuid.uuid4())
+def _hash_ascii(s):
+ #return md5(s).hexdigest() # Markdown.pl effectively does this.
+ return 'md5-' + md5(SECRET_SALT + s).hexdigest()
+def _hash_text(s):
+ return 'md5-' + md5(SECRET_SALT + s.encode("utf-8")).hexdigest()
+
+# Table of hash values for escaped characters:
+g_escape_table = dict([(ch, _hash_ascii(ch))
+ for ch in '\\`*_{}[]()>#+-.!'])
+
+
+
+#---- exceptions
+
+class MarkdownError(Exception):
+ pass
+
+
+
+#---- public api
+
+def markdown_path(path, encoding="utf-8",
+ html4tags=False, tab_width=DEFAULT_TAB_WIDTH,
+ safe_mode=None, extras=None, link_patterns=None,
+ use_file_vars=False):
+ fp = codecs.open(path, 'r', encoding)
+ text = fp.read()
+ fp.close()
+ return Markdown(html4tags=html4tags, tab_width=tab_width,
+ safe_mode=safe_mode, extras=extras,
+ link_patterns=link_patterns,
+ use_file_vars=use_file_vars).convert(text)
+
+def markdown(text, html4tags=False, tab_width=DEFAULT_TAB_WIDTH,
+ safe_mode=None, extras=None, link_patterns=None,
+ use_file_vars=False):
+ return Markdown(html4tags=html4tags, tab_width=tab_width,
+ safe_mode=safe_mode, extras=extras,
+ link_patterns=link_patterns,
+ use_file_vars=use_file_vars).convert(text)
+
+class Markdown(object):
+ # The dict of "extras" to enable in processing -- a mapping of
+ # extra name to argument for the extra. Most extras do not have an
+ # argument, in which case the value is None.
+ #
+ # This can be set via (a) subclassing and (b) the constructor
+ # "extras" argument.
+ extras = None
+
+ urls = None
+ titles = None
+ html_blocks = None
+ html_spans = None
+ html_removed_text = "[HTML_REMOVED]" # for compat with markdown.py
+
+ # Used to track when we're inside an ordered or unordered list
+ # (see _ProcessListItems() for details):
+ list_level = 0
+
+ _ws_only_line_re = re.compile(r"^[ \t]+$", re.M)
+
+ def __init__(self, html4tags=False, tab_width=4, safe_mode=None,
+ extras=None, link_patterns=None, use_file_vars=False):
+ if html4tags:
+ self.empty_element_suffix = ">"
+ else:
+ self.empty_element_suffix = " />"
+ self.tab_width = tab_width
+
+ # For compatibility with earlier markdown2.py and with
+ # markdown.py's safe_mode being a boolean,
+ # safe_mode == True -> "replace"
+ if safe_mode is True:
+ self.safe_mode = "replace"
+ else:
+ self.safe_mode = safe_mode
+
+ if self.extras is None:
+ self.extras = {}
+ elif not isinstance(self.extras, dict):
+ self.extras = dict([(e, None) for e in self.extras])
+ if extras:
+ if not isinstance(extras, dict):
+ extras = dict([(e, None) for e in extras])
+ self.extras.update(extras)
+ assert isinstance(self.extras, dict)
+ if "toc" in self.extras and not "header-ids" in self.extras:
+ self.extras["header-ids"] = None # "toc" implies "header-ids"
+ self._instance_extras = self.extras.copy()
+ self.link_patterns = link_patterns
+ self.use_file_vars = use_file_vars
+ self._outdent_re = re.compile(r'^(\t|[ ]{1,%d})' % tab_width, re.M)
+
+ def reset(self):
+ self.urls = {}
+ self.titles = {}
+ self.html_blocks = {}
+ self.html_spans = {}
+ self.list_level = 0
+ self.extras = self._instance_extras.copy()
+ if "footnotes" in self.extras:
+ self.footnotes = {}
+ self.footnote_ids = []
+ if "header-ids" in self.extras:
+ self._count_from_header_id = {} # no `defaultdict` in Python 2.4
+
+ def convert(self, text):
+ """Convert the given text."""
+ # Main function. The order in which other subs are called here is
+ # essential. Link and image substitutions need to happen before
+ # _EscapeSpecialChars(), so that any *'s or _'s in the <a>
+ # and <img> tags get encoded.
+
+ # Clear the global hashes. If we don't clear these, you get conflicts
+ # from other articles when generating a page which contains more than
+ # one article (e.g. an index page that shows the N most recent
+ # articles):
+ self.reset()
+
+ if not isinstance(text, unicode):
+ #TODO: perhaps shouldn't presume UTF-8 for string input?
+ text = unicode(text, 'utf-8')
+
+ if self.use_file_vars:
+ # Look for emacs-style file variable hints.
+ emacs_vars = self._get_emacs_vars(text)
+ if "markdown-extras" in emacs_vars:
+ splitter = re.compile("[ ,]+")
+ for e in splitter.split(emacs_vars["markdown-extras"]):
+ if '=' in e:
+ ename, earg = e.split('=', 1)
+ try:
+ earg = int(earg)
+ except ValueError:
+ pass
+ else:
+ ename, earg = e, None
+ self.extras[ename] = earg
+
+ # Standardize line endings:
+ text = re.sub("\r\n|\r", "\n", text)
+
+ # Make sure $text ends with a couple of newlines:
+ text += "\n\n"
+
+ # Convert all tabs to spaces.
+ text = self._detab(text)
+
+ # Strip any lines consisting only of spaces and tabs.
+ # This makes subsequent regexen easier to write, because we can
+ # match consecutive blank lines with /\n+/ instead of something
+ # contorted like /[ \t]*\n+/ .
+ text = self._ws_only_line_re.sub("", text)
+
+ if self.safe_mode:
+ text = self._hash_html_spans(text)
+
+ # Turn block-level HTML blocks into hash entries
+ text = self._hash_html_blocks(text, raw=True)
+
+ # Strip link definitions, store in hashes.
+ if "footnotes" in self.extras:
+ # Must do footnotes first because an unlucky footnote defn
+ # looks like a link defn:
+ # [^4]: this "looks like a link defn"
+ text = self._strip_footnote_definitions(text)
+ text = self._strip_link_definitions(text)
+
+ text = self._run_block_gamut(text)
+
+ if "footnotes" in self.extras:
+ text = self._add_footnotes(text)
+
+ text = self._unescape_special_chars(text)
+
+ if self.safe_mode:
+ text = self._unhash_html_spans(text)
+
+ #text += "\n"
+
+ rv = UnicodeWithAttrs(text)
+ if "toc" in self.extras:
+ rv._toc = self._toc
+ return rv
+
+ _emacs_oneliner_vars_pat = re.compile(r"-\*-\s*([^\r\n]*?)\s*-\*-", re.UNICODE)
+ # This regular expression is intended to match blocks like this:
+ # PREFIX Local Variables: SUFFIX
+ # PREFIX mode: Tcl SUFFIX
+ # PREFIX End: SUFFIX
+ # Some notes:
+ # - "[ \t]" is used instead of "\s" to specifically exclude newlines
+ # - "(\r\n|\n|\r)" is used instead of "$" because the sre engine does
+ # not like anything other than Unix-style line terminators.
+ _emacs_local_vars_pat = re.compile(r"""^
+ (?P<prefix>(?:[^\r\n|\n|\r])*?)
+ [\ \t]*Local\ Variables:[\ \t]*
+ (?P<suffix>.*?)(?:\r\n|\n|\r)
+ (?P<content>.*?\1End:)
+ """, re.IGNORECASE | re.MULTILINE | re.DOTALL | re.VERBOSE)
+
+ def _get_emacs_vars(self, text):
+ """Return a dictionary of emacs-style local variables.
+
+ Parsing is done loosely according to this spec (and according to
+ some in-practice deviations from this):
+ http://www.gnu.org/software/emacs/manual/html_node/emacs/Specifying-File-Variables.html#Specifying-File-Variables
+ """
+ emacs_vars = {}
+ SIZE = pow(2, 13) # 8kB
+
+ # Search near the start for a '-*-'-style one-liner of variables.
+ head = text[:SIZE]
+ if "-*-" in head:
+ match = self._emacs_oneliner_vars_pat.search(head)
+ if match:
+ emacs_vars_str = match.group(1)
+ assert '\n' not in emacs_vars_str
+ emacs_var_strs = [s.strip() for s in emacs_vars_str.split(';')
+ if s.strip()]
+ if len(emacs_var_strs) == 1 and ':' not in emacs_var_strs[0]:
+ # While not in the spec, this form is allowed by emacs:
+ # -*- Tcl -*-
+ # where the implied "variable" is "mode". This form
+ # is only allowed if there are no other variables.
+ emacs_vars["mode"] = emacs_var_strs[0].strip()
+ else:
+ for emacs_var_str in emacs_var_strs:
+ try:
+ variable, value = emacs_var_str.strip().split(':', 1)
+ except ValueError:
+ log.debug("emacs variables error: malformed -*- "
+ "line: %r", emacs_var_str)
+ continue
+ # Lowercase the variable name because Emacs allows "Mode"
+ # or "mode" or "MoDe", etc.
+ emacs_vars[variable.lower()] = value.strip()
+
+ tail = text[-SIZE:]
+ if "Local Variables" in tail:
+ match = self._emacs_local_vars_pat.search(tail)
+ if match:
+ prefix = match.group("prefix")
+ suffix = match.group("suffix")
+ lines = match.group("content").splitlines(0)
+ #print "prefix=%r, suffix=%r, content=%r, lines: %s"\
+ # % (prefix, suffix, match.group("content"), lines)
+
+ # Validate the Local Variables block: proper prefix and suffix
+ # usage.
+ for i, line in enumerate(lines):
+ if not line.startswith(prefix):
+ log.debug("emacs variables error: line '%s' "
+ "does not use proper prefix '%s'"
+ % (line, prefix))
+ return {}
+ # Don't validate suffix on last line. Emacs doesn't care,
+ # neither should we.
+ if i != len(lines)-1 and not line.endswith(suffix):
+ log.debug("emacs variables error: line '%s' "
+ "does not use proper suffix '%s'"
+ % (line, suffix))
+ return {}
+
+ # Parse out one emacs var per line.
+ continued_for = None
+ for line in lines[:-1]: # no var on the last line ("PREFIX End:")
+ if prefix: line = line[len(prefix):] # strip prefix
+ if suffix: line = line[:-len(suffix)] # strip suffix
+ line = line.strip()
+ if continued_for:
+ variable = continued_for
+ if line.endswith('\\'):
+ line = line[:-1].rstrip()
+ else:
+ continued_for = None
+ emacs_vars[variable] += ' ' + line
+ else:
+ try:
+ variable, value = line.split(':', 1)
+ except ValueError:
+ log.debug("local variables error: missing colon "
+ "in local variables entry: '%s'" % line)
+ continue
+ # Do NOT lowercase the variable name, because Emacs only
+ # allows "mode" (and not "Mode", "MoDe", etc.) in this block.
+ value = value.strip()
+ if value.endswith('\\'):
+ value = value[:-1].rstrip()
+ continued_for = variable
+ else:
+ continued_for = None
+ emacs_vars[variable] = value
+
+ # Unquote values.
+ for var, val in emacs_vars.items():
+ if len(val) > 1 and (val.startswith('"') and val.endswith('"')
+ or val.startswith('"') and val.endswith('"')):
+ emacs_vars[var] = val[1:-1]
+
+ return emacs_vars
+
+ # Cribbed from a post by Bart Lateur:
+ # <http://www.nntp.perl.org/group/perl.macperl.anyperl/154>
+ _detab_re = re.compile(r'(.*?)\t', re.M)
+ def _detab_sub(self, match):
+ g1 = match.group(1)
+ return g1 + (' ' * (self.tab_width - len(g1) % self.tab_width))
+ def _detab(self, text):
+ r"""Remove (leading?) tabs from a file.
+
+ >>> m = Markdown()
+ >>> m._detab("\tfoo")
+ ' foo'
+ >>> m._detab(" \tfoo")
+ ' foo'
+ >>> m._detab("\t foo")
+ ' foo'
+ >>> m._detab(" foo")
+ ' foo'
+ >>> m._detab(" foo\n\tbar\tblam")
+ ' foo\n bar blam'
+ """
+ if '\t' not in text:
+ return text
+ return self._detab_re.subn(self._detab_sub, text)[0]
+
+ _block_tags_a = 'p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|ins|del'
+ _strict_tag_block_re = re.compile(r"""
+ ( # save in \1
+ ^ # start of line (with re.M)
+ <(%s) # start tag = \2
+ \b # word break
+ (.*\n)*? # any number of lines, minimally matching
+ </\2> # the matching end tag
+ [ \t]* # trailing spaces/tabs
+ (?=\n+|\Z) # followed by a newline or end of document
+ )
+ """ % _block_tags_a,
+ re.X | re.M)
+
+ _block_tags_b = 'p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math'
+ _liberal_tag_block_re = re.compile(r"""
+ ( # save in \1
+ ^ # start of line (with re.M)
+ <(%s) # start tag = \2
+ \b # word break
+ (.*\n)*? # any number of lines, minimally matching
+ .*</\2> # the matching end tag
+ [ \t]* # trailing spaces/tabs
+ (?=\n+|\Z) # followed by a newline or end of document
+ )
+ """ % _block_tags_b,
+ re.X | re.M)
+
+ def _hash_html_block_sub(self, match, raw=False):
+ html = match.group(1)
+ if raw and self.safe_mode:
+ html = self._sanitize_html(html)
+ key = _hash_text(html)
+ self.html_blocks[key] = html
+ return "\n\n" + key + "\n\n"
+
+ def _hash_html_blocks(self, text, raw=False):
+ """Hashify HTML blocks
+
+ We only want to do this for block-level HTML tags, such as headers,
+ lists, and tables. That's because we still want to wrap <p>s around
+ "paragraphs" that are wrapped in non-block-level tags, such as anchors,
+ phrase emphasis, and spans. The list of tags we're looking for is
+ hard-coded.
+
+ @param raw {boolean} indicates if these are raw HTML blocks in
+ the original source. It makes a difference in "safe" mode.
+ """
+ if '<' not in text:
+ return text
+
+ # Pass `raw` value into our calls to self._hash_html_block_sub.
+ hash_html_block_sub = _curry(self._hash_html_block_sub, raw=raw)
+
+ # First, look for nested blocks, e.g.:
+ # <div>
+ # <div>
+ # tags for inner block must be indented.
+ # </div>
+ # </div>
+ #
+ # The outermost tags must start at the left margin for this to match, and
+ # the inner nested divs must be indented.
+ # We need to do this before the next, more liberal match, because the next
+ # match will start at the first `<div>` and stop at the first `</div>`.
+ text = self._strict_tag_block_re.sub(hash_html_block_sub, text)
+
+ # Now match more liberally, simply from `\n<tag>` to `</tag>\n`
+ text = self._liberal_tag_block_re.sub(hash_html_block_sub, text)
+
+ # Special case just for <hr />. It was easier to make a special
+ # case than to make the other regex more complicated.
+ if "<hr" in text:
+ _hr_tag_re = _hr_tag_re_from_tab_width(self.tab_width)
+ text = _hr_tag_re.sub(hash_html_block_sub, text)
+
+ # Special case for standalone HTML comments:
+ if "<!--" in text:
+ start = 0
+ while True:
+ # Delimiters for next comment block.
+ try:
+ start_idx = text.index("<!--", start)
+ except ValueError, ex:
+ break
+ try:
+ end_idx = text.index("-->", start_idx) + 3
+ except ValueError, ex:
+ break
+
+ # Start position for next comment block search.
+ start = end_idx
+
+ # Validate whitespace before comment.
+ if start_idx:
+ # - Up to `tab_width - 1` spaces before start_idx.
+ for i in range(self.tab_width - 1):
+ if text[start_idx - 1] != ' ':
+ break
+ start_idx -= 1
+ if start_idx == 0:
+ break
+ # - Must be preceded by 2 newlines or hit the start of
+ # the document.
+ if start_idx == 0:
+ pass
+ elif start_idx == 1 and text[0] == '\n':
+ start_idx = 0 # to match minute detail of Markdown.pl regex
+ elif text[start_idx-2:start_idx] == '\n\n':
+ pass
+ else:
+ break
+
+ # Validate whitespace after comment.
+ # - Any number of spaces and tabs.
+ while end_idx < len(text):
+ if text[end_idx] not in ' \t':
+ break
+ end_idx += 1
+ # - Must be following by 2 newlines or hit end of text.
+ if text[end_idx:end_idx+2] not in ('', '\n', '\n\n'):
+ continue
+
+ # Escape and hash (must match `_hash_html_block_sub`).
+ html = text[start_idx:end_idx]
+ if raw and self.safe_mode:
+ html = self._sanitize_html(html)
+ key = _hash_text(html)
+ self.html_blocks[key] = html
+ text = text[:start_idx] + "\n\n" + key + "\n\n" + text[end_idx:]
+
+ if "xml" in self.extras:
+ # Treat XML processing instructions and namespaced one-liner
+ # tags as if they were block HTML tags. E.g., if standalone
+ # (i.e. are their own paragraph), the following do not get
+ # wrapped in a <p> tag:
+ # <?foo bar?>
+ #
+ # <xi:include xmlns:xi="http://www.w3.org/2001/XInclude" href="chapter_1.md"/>
+ _xml_oneliner_re = _xml_oneliner_re_from_tab_width(self.tab_width)
+ text = _xml_oneliner_re.sub(hash_html_block_sub, text)
+
+ return text
+
+ def _strip_link_definitions(self, text):
+ # Strips link definitions from text, stores the URLs and titles in
+ # hash references.
+ less_than_tab = self.tab_width - 1
+
+ # Link defs are in the form:
+ # [id]: url "optional title"
+ _link_def_re = re.compile(r"""
+ ^[ ]{0,%d}\[(.+)\]: # id = \1
+ [ \t]*
+ \n? # maybe *one* newline
+ [ \t]*
+ <?(.+?)>? # url = \2
+ [ \t]*
+ (?:
+ \n? # maybe one newline
+ [ \t]*
+ (?<=\s) # lookbehind for whitespace
+ ['"(]
+ ([^\n]*) # title = \3
+ ['")]
+ [ \t]*
+ )? # title is optional
+ (?:\n+|\Z)
+ """ % less_than_tab, re.X | re.M | re.U)
+ return _link_def_re.sub(self._extract_link_def_sub, text)
+
+ def _extract_link_def_sub(self, match):
+ id, url, title = match.groups()
+ key = id.lower() # Link IDs are case-insensitive
+ self.urls[key] = self._encode_amps_and_angles(url)
+ if title:
+ self.titles[key] = title.replace('"', '&quot;')
+ return ""
+
+ def _extract_footnote_def_sub(self, match):
+ id, text = match.groups()
+ text = _dedent(text, skip_first_line=not text.startswith('\n')).strip()
+ normed_id = re.sub(r'\W', '-', id)
+ # Ensure footnote text ends with a couple newlines (for some
+ # block gamut matches).
+ self.footnotes[normed_id] = text + "\n\n"
+ return ""
+
+ def _strip_footnote_definitions(self, text):
+ """A footnote definition looks like this:
+
+ [^note-id]: Text of the note.
+
+ May include one or more indented paragraphs.
+
+ Where,
+ - The 'note-id' can be pretty much anything, though typically it
+ is the number of the footnote.
+ - The first paragraph may start on the next line, like so:
+
+ [^note-id]:
+ Text of the note.
+ """
+ less_than_tab = self.tab_width - 1
+ footnote_def_re = re.compile(r'''
+ ^[ ]{0,%d}\[\^(.+)\]: # id = \1
+ [ \t]*
+ ( # footnote text = \2
+ # First line need not start with the spaces.
+ (?:\s*.*\n+)
+ (?:
+ (?:[ ]{%d} | \t) # Subsequent lines must be indented.
+ .*\n+
+ )*
+ )
+ # Lookahead for non-space at line-start, or end of doc.
+ (?:(?=^[ ]{0,%d}\S)|\Z)
+ ''' % (less_than_tab, self.tab_width, self.tab_width),
+ re.X | re.M)
+ return footnote_def_re.sub(self._extract_footnote_def_sub, text)
+
+
+ _hr_res = [
+ re.compile(r"^[ ]{0,2}([ ]?\*[ ]?){3,}[ \t]*$", re.M),
+ re.compile(r"^[ ]{0,2}([ ]?\-[ ]?){3,}[ \t]*$", re.M),
+ re.compile(r"^[ ]{0,2}([ ]?\_[ ]?){3,}[ \t]*$", re.M),
+ ]
+
+ def _run_block_gamut(self, text):
+ # These are all the transformations that form block-level
+ # tags like paragraphs, headers, and list items.
+
+ #text = self._do_headers(text)
+
+ # Do Horizontal Rules:
+ #hr = "\n<hr"+self.empty_element_suffix+"\n"
+ #for hr_re in self._hr_res:
+ # text = hr_re.sub(hr, text)
+
+ text = self._do_lists(text)
+
+ if "pyshell" in self.extras:
+ text = self._prepare_pyshell_blocks(text)
+
+ text = self._do_code_blocks(text)
+
+ text = self._do_block_quotes(text)
+
+ # We already ran _HashHTMLBlocks() before, in Markdown(), but that
+ # was to escape raw HTML in the original Markdown source. This time,
+ # we're escaping the markup we've just created, so that we don't wrap
+ # <p> tags around block-level tags.
+ text = self._hash_html_blocks(text)
+
+ text = self._form_paragraphs(text)
+
+ return text
+
+ def _pyshell_block_sub(self, match):
+ lines = match.group(0).splitlines(0)
+ _dedentlines(lines)
+ indent = ' ' * self.tab_width
+ s = ('\n' # separate from possible cuddled paragraph
+ + indent + ('\n'+indent).join(lines)
+ + '\n\n')
+ return s
+
+ def _prepare_pyshell_blocks(self, text):
+ """Ensure that Python interactive shell sessions are put in
+ code blocks -- even if not properly indented.
+ """
+ if ">>>" not in text:
+ return text
+
+ less_than_tab = self.tab_width - 1
+ _pyshell_block_re = re.compile(r"""
+ ^([ ]{0,%d})>>>[ ].*\n # first line
+ ^(\1.*\S+.*\n)* # any number of subsequent lines
+ ^\n # ends with a blank line
+ """ % less_than_tab, re.M | re.X)
+
+ return _pyshell_block_re.sub(self._pyshell_block_sub, text)
+
+ def _run_span_gamut(self, text):
+ # These are all the transformations that occur *within* block-level
+ # tags like paragraphs, headers, and list items.
+
+ #text = self._do_code_spans(text) - El AA !
+
+ text = self._escape_special_chars(text)
+
+ # Process anchor and image tags.
+ text = self._do_links(text)
+
+ # Make links out of things like `<http://example.com/>`
+ # Must come after _do_links(), because you can use < and >
+ # delimiters in inline links like [this](<url>).
+ #text = self._do_auto_links(text)
+
+ if "link-patterns" in self.extras:
+ text = self._do_link_patterns(text)
+
+ text = self._encode_amps_and_angles(text)
+
+ text = self._do_italics_and_bold(text)
+
+ # Do hard breaks:
+ text = re.sub(r"\n", "<br%s" % self.empty_element_suffix, text)
+
+ return text
+
+ # "Sorta" because auto-links are identified as "tag" tokens.
+ _sorta_html_tokenize_re = re.compile(r"""
+ (
+ # tag
+ </?
+ (?:\w+) # tag name
+ (?:\s+(?:[\w-]+:)?[\w-]+=(?:".*?"|'.*?'))* # attributes
+ \s*/?>
+ |
+ # auto-link (e.g., <http://www.activestate.com/>)
+ <\w+[^>]*>
+ |
+ <!--.*?--> # comment
+ |
+ <\?.*?\?> # processing instruction
+ )
+ """, re.X)
+
+ def _escape_special_chars(self, text):
+ # Python markdown note: the HTML tokenization here differs from
+ # that in Markdown.pl, hence the behaviour for subtle cases can
+ # differ (I believe the tokenizer here does a better job because
+ # it isn't susceptible to unmatched '<' and '>' in HTML tags).
+ # Note, however, that '>' is not allowed in an auto-link URL
+ # here.
+ escaped = []
+ is_html_markup = False
+ for token in self._sorta_html_tokenize_re.split(text):
+ if is_html_markup:
+ # Within tags/HTML-comments/auto-links, encode * and _
+ # so they don't conflict with their use in Markdown for
+ # italics and strong. We're replacing each such
+ # character with its corresponding MD5 checksum value;
+ # this is likely overkill, but it should prevent us from
+ # colliding with the escape values by accident.
+ escaped.append(token.replace('*', g_escape_table['*'])
+ .replace('_', g_escape_table['_']))
+ else:
+ escaped.append(self._encode_backslash_escapes(token))
+ is_html_markup = not is_html_markup
+ return ''.join(escaped)
+
+ def _hash_html_spans(self, text):
+ # Used for safe_mode.
+
+ def _is_auto_link(s):
+ if ':' in s and self._auto_link_re.match(s):
+ return True
+ elif '@' in s and self._auto_email_link_re.match(s):
+ return True
+ return False
+
+ tokens = []
+ is_html_markup = False
+ for token in self._sorta_html_tokenize_re.split(text):
+ if is_html_markup and not _is_auto_link(token):
+ sanitized = self._sanitize_html(token)
+ key = _hash_text(sanitized)
+ self.html_spans[key] = sanitized
+ tokens.append(key)
+ else:
+ tokens.append(token)
+ is_html_markup = not is_html_markup
+ return ''.join(tokens)
+
+ def _unhash_html_spans(self, text):
+ for key, sanitized in self.html_spans.items():
+ text = text.replace(key, sanitized)
+ return text
+
+ def _sanitize_html(self, s):
+ if self.safe_mode == "replace":
+ return self.html_removed_text
+ elif self.safe_mode == "escape":
+ replacements = [
+ ('&', '&amp;'),
+ ('<', '&lt;'),
+ ('>', '&gt;'),
+ ]
+ for before, after in replacements:
+ s = s.replace(before, after)
+ return s
+ else:
+ raise MarkdownError("invalid value for 'safe_mode': %r (must be "
+ "'escape' or 'replace')" % self.safe_mode)
+
+ _tail_of_inline_link_re = re.compile(r'''
+ # Match tail of: [text](/url/) or [text](/url/ "title")
+ \( # literal paren
+ [ \t]*
+ (?P<url> # \1
+ <.*?>
+ |
+ .*?
+ )
+ [ \t]*
+ ( # \2
+ (['"]) # quote char = \3
+ (?P<title>.*?)
+ \3 # matching quote
+ )? # title is optional
+ \)
+ ''', re.X | re.S)
+ _tail_of_reference_link_re = re.compile(r'''
+ # Match tail of: [text][id]
+ [ ]? # one optional space
+ (?:\n[ ]*)? # one optional newline followed by spaces
+ \[
+ (?P<id>.*?)
+ \]
+ ''', re.X | re.S)
+
+ def _do_links(self, text):
+ """Turn Markdown link shortcuts into XHTML <a> and <img> tags.
+
+ This is a combination of Markdown.pl's _DoAnchors() and
+ _DoImages(). They are done together because that simplified the
+ approach. It was necessary to use a different approach than
+ Markdown.pl because of the lack of atomic matching support in
+ Python's regex engine used in $g_nested_brackets.
+ """
+ MAX_LINK_TEXT_SENTINEL = 3000 # markdown2 issue 24
+
+ # `anchor_allowed_pos` is used to support img links inside
+ # anchors, but not anchors inside anchors. An anchor's start
+ # pos must be `>= anchor_allowed_pos`.
+ anchor_allowed_pos = 0
+
+ curr_pos = 0
+ while True: # Handle the next link.
+ # The next '[' is the start of:
+ # - an inline anchor: [text](url "title")
+ # - a reference anchor: [text][id]
+ # - an inline img: ![text](url "title")
+ # - a reference img: ![text][id]
+ # - a footnote ref: [^id]
+ # (Only if 'footnotes' extra enabled)
+ # - a footnote defn: [^id]: ...
+ # (Only if 'footnotes' extra enabled) These have already
+ # been stripped in _strip_footnote_definitions() so no
+ # need to watch for them.
+ # - a link definition: [id]: url "title"
+ # These have already been stripped in
+ # _strip_link_definitions() so no need to watch for them.
+ # - not markup: [...anything else...
+ try:
+ start_idx = text.index('[', curr_pos)
+ except ValueError:
+ break
+ text_length = len(text)
+
+ # Find the matching closing ']'.
+ # Markdown.pl allows *matching* brackets in link text so we
+ # will here too. Markdown.pl *doesn't* currently allow
+ # matching brackets in img alt text -- we'll differ in that
+ # regard.
+ bracket_depth = 0
+ for p in range(start_idx+1, min(start_idx+MAX_LINK_TEXT_SENTINEL,
+ text_length)):
+ ch = text[p]
+ if ch == ']':
+ bracket_depth -= 1
+ if bracket_depth < 0:
+ break
+ elif ch == '[':
+ bracket_depth += 1
+ else:
+ # Closing bracket not found within sentinel length.
+ # This isn't markup.
+ curr_pos = start_idx + 1
+ continue
+ link_text = text[start_idx+1:p]
+
+ # Possibly a footnote ref?
+ if "footnotes" in self.extras and link_text.startswith("^"):
+ normed_id = re.sub(r'\W', '-', link_text[1:])
+ if normed_id in self.footnotes:
+ self.footnote_ids.append(normed_id)
+ result = '<sup class="footnote-ref" id="fnref-%s">' \
+ '<a href="#fn-%s">%s</a></sup>' \
+ % (normed_id, normed_id, len(self.footnote_ids))
+ text = text[:start_idx] + result + text[p+1:]
+ else:
+ # This id isn't defined, leave the markup alone.
+ curr_pos = p+1
+ continue
+
+ # Now determine what this is by the remainder.
+ p += 1
+ if p == text_length:
+ return text
+
+ # Inline anchor or img?
+ if text[p] == '(': # attempt at perf improvement
+ match = self._tail_of_inline_link_re.match(text, p)
+ if match:
+ # Handle an inline anchor or img.
+ #is_img = start_idx > 0 and text[start_idx-1] == "!"
+ #if is_img:
+ # start_idx -= 1
+ is_img = False
+
+ url, title = match.group("url"), match.group("title")
+ if url and url[0] == '<':
+ url = url[1:-1] # '<url>' -> 'url'
+ # We've got to encode these to avoid conflicting
+ # with italics/bold.
+ url = url.replace('*', g_escape_table['*']) \
+ .replace('_', g_escape_table['_'])
+ if title:
+ title_str = ' title="%s"' \
+ % title.replace('*', g_escape_table['*']) \
+ .replace('_', g_escape_table['_']) \
+ .replace('"', '&quot;')
+ else:
+ title_str = ''
+ if is_img:
+ result = '<img src="%s" alt="%s"%s%s' \
+ % (url.replace('"', '&quot;'),
+ link_text.replace('"', '&quot;'),
+ title_str, self.empty_element_suffix)
+ curr_pos = start_idx + len(result)
+ text = text[:start_idx] + result + text[match.end():]
+ elif start_idx >= anchor_allowed_pos:
+ result_head = '<a href="%s"%s>' % (url, title_str)
+ result = '%s%s</a>' % (result_head, link_text)
+ # <img> allowed from curr_pos on, <a> from
+ # anchor_allowed_pos on.
+ curr_pos = start_idx + len(result_head)
+ anchor_allowed_pos = start_idx + len(result)
+ text = text[:start_idx] + result + text[match.end():]
+ else:
+ # Anchor not allowed here.
+ curr_pos = start_idx + 1
+ continue
+
+ # Reference anchor or img?
+ else:
+ match = self._tail_of_reference_link_re.match(text, p)
+ if match:
+ # Handle a reference-style anchor or img.
+ #is_img = start_idx > 0 and text[start_idx-1] == "!"
+ #if is_img:
+ # start_idx -= 1
+ is_img = False
+
+ link_id = match.group("id").lower()
+ if not link_id:
+ link_id = link_text.lower() # for links like [this][]
+ if link_id in self.urls:
+ url = self.urls[link_id]
+ # We've got to encode these to avoid conflicting
+ # with italics/bold.
+ url = url.replace('*', g_escape_table['*']) \
+ .replace('_', g_escape_table['_'])
+ title = self.titles.get(link_id)
+ if title:
+ title = title.replace('*', g_escape_table['*']) \
+ .replace('_', g_escape_table['_'])
+ title_str = ' title="%s"' % title
+ else:
+ title_str = ''
+ if is_img:
+ result = '<img src="%s" alt="%s"%s%s' \
+ % (url.replace('"', '&quot;'),
+ link_text.replace('"', '&quot;'),
+ title_str, self.empty_element_suffix)
+ curr_pos = start_idx + len(result)
+ text = text[:start_idx] + result + text[match.end():]
+ elif start_idx >= anchor_allowed_pos:
+ result = '<a href="%s"%s>%s</a>' \
+ % (url, title_str, link_text)
+ result_head = '<a href="%s"%s>' % (url, title_str)
+ result = '%s%s</a>' % (result_head, link_text)
+ # <img> allowed from curr_pos on, <a> from
+ # anchor_allowed_pos on.
+ curr_pos = start_idx + len(result_head)
+ anchor_allowed_pos = start_idx + len(result)
+ text = text[:start_idx] + result + text[match.end():]
+ else:
+ # Anchor not allowed here.
+ curr_pos = start_idx + 1
+ else:
+ # This id isn't defined, leave the markup alone.
+ curr_pos = match.end()
+ continue
+
+ # Otherwise, it isn't markup.
+ curr_pos = start_idx + 1
+
+ return text
+
+ def header_id_from_text(self, text, prefix):
+ """Generate a header id attribute value from the given header
+ HTML content.
+
+ This is only called if the "header-ids" extra is enabled.
+ Subclasses may override this for different header ids.
+ """
+ header_id = _slugify(text)
+ if prefix:
+ header_id = prefix + '-' + header_id
+ if header_id in self._count_from_header_id:
+ self._count_from_header_id[header_id] += 1
+ header_id += '-%s' % self._count_from_header_id[header_id]
+ else:
+ self._count_from_header_id[header_id] = 1
+ return header_id
+
+ _toc = None
+ def _toc_add_entry(self, level, id, name):
+ if self._toc is None:
+ self._toc = []
+ self._toc.append((level, id, name))
+
+ _setext_h_re = re.compile(r'^(.+)[ \t]*\n(=+|-+)[ \t]*\n+', re.M)
+ def _setext_h_sub(self, match):
+ n = {"=": 1, "-": 2}[match.group(2)[0]]
+ demote_headers = self.extras.get("demote-headers")
+ if demote_headers:
+ n = min(n + demote_headers, 6)
+ header_id_attr = ""
+ if "header-ids" in self.extras:
+ header_id = self.header_id_from_text(match.group(1),
+ prefix=self.extras["header-ids"])
+ header_id_attr = ' id="%s"' % header_id
+ html = self._run_span_gamut(match.group(1))
+ if "toc" in self.extras:
+ self._toc_add_entry(n, header_id, html)
+ return "<h%d%s>%s</h%d>\n\n" % (n, header_id_attr, html, n)
+
+ _atx_h_re = re.compile(r'''
+ ^(\#{1,6}) # \1 = string of #'s
+ [ \t]*
+ (.+?) # \2 = Header text
+ [ \t]*
+ (?<!\\) # ensure not an escaped trailing '#'
+ \#* # optional closing #'s (not counted)
+ \n+
+ ''', re.X | re.M)
+ def _atx_h_sub(self, match):
+ n = len(match.group(1))
+ demote_headers = self.extras.get("demote-headers")
+ if demote_headers:
+ n = min(n + demote_headers, 6)
+ header_id_attr = ""
+ if "header-ids" in self.extras:
+ header_id = self.header_id_from_text(match.group(2),
+ prefix=self.extras["header-ids"])
+ header_id_attr = ' id="%s"' % header_id
+ html = self._run_span_gamut(match.group(2))
+ if "toc" in self.extras:
+ self._toc_add_entry(n, header_id, html)
+ return "<h%d%s>%s</h%d>\n\n" % (n, header_id_attr, html, n)
+
+ def _do_headers(self, text):
+ # Setext-style headers:
+ # Header 1
+ # ========
+ #
+ # Header 2
+ # --------
+ text = self._setext_h_re.sub(self._setext_h_sub, text)
+
+ # atx-style headers:
+ # # Header 1
+ # ## Header 2
+ # ## Header 2 with closing hashes ##
+ # ...
+ # ###### Header 6
+ text = self._atx_h_re.sub(self._atx_h_sub, text)
+
+ return text
+
+
+ _marker_ul_chars = '*+-'
+ _marker_any = r'(?:[%s]|\d+\.)' % _marker_ul_chars
+ _marker_ul = '(?:[%s])' % _marker_ul_chars
+ _marker_ol = r'(?:\d+\.)'
+
+ def _list_sub(self, match):
+ lst = match.group(1)
+ lst_type = match.group(3) in self._marker_ul_chars and "ul" or "ol"
+ result = self._process_list_items(lst)
+ if self.list_level:
+ return "<%s>\n%s</%s>\n" % (lst_type, result, lst_type)
+ else:
+ return "<%s>\n%s</%s>\n\n" % (lst_type, result, lst_type)
+
+ def _do_lists(self, text):
+ # Form HTML ordered (numbered) and unordered (bulleted) lists.
+
+ for marker_pat in (self._marker_ul, self._marker_ol):
+ # Re-usable pattern to match any entire ul or ol list:
+ less_than_tab = self.tab_width - 1
+ whole_list = r'''
+ ( # \1 = whole list
+ ( # \2
+ [ ]{0,%d}
+ (%s) # \3 = first list item marker
+ [ \t]+
+ )
+ (?:.+?)
+ ( # \4
+ \Z
+ |
+ \n{2,}
+ (?=\S)
+ (?! # Negative lookahead for another list item marker
+ [ \t]*
+ %s[ \t]+
+ )
+ )
+ )
+ ''' % (less_than_tab, marker_pat, marker_pat)
+
+ # We use a different prefix before nested lists than top-level lists.
+ # See extended comment in _process_list_items().
+ #
+ # Note: There's a bit of duplication here. My original implementation
+ # created a scalar regex pattern as the conditional result of the test on
+ # $g_list_level, and then only ran the $text =~ s{...}{...}egmx
+ # substitution once, using the scalar as the pattern. This worked,
+ # everywhere except when running under MT on my hosting account at Pair
+ # Networks. There, this caused all rebuilds to be killed by the reaper (or
+ # perhaps they crashed, but that seems incredibly unlikely given that the
+ # same script on the same server ran fine *except* under MT. I've spent
+ # more time trying to figure out why this is happening than I'd like to
+ # admit. My only guess, backed up by the fact that this workaround works,
+ # is that Perl optimizes the substition when it can figure out that the
+ # pattern will never change, and when this optimization isn't on, we run
+ # afoul of the reaper. Thus, the slightly redundant code to that uses two
+ # static s/// patterns rather than one conditional pattern.
+
+ if self.list_level:
+ sub_list_re = re.compile("^"+whole_list, re.X | re.M | re.S)
+ text = sub_list_re.sub(self._list_sub, text)
+ else:
+ list_re = re.compile(r"(?:(?<=\n\n)|\A\n?)"+whole_list,
+ re.X | re.M | re.S)
+ text = list_re.sub(self._list_sub, text)
+
+ return text
+
+ _list_item_re = re.compile(r'''
+ (\n)? # leading line = \1
+ (^[ \t]*) # leading whitespace = \2
+ (?P<marker>%s) [ \t]+ # list marker = \3
+ ((?:.+?) # list item text = \4
+ (\n{1,2})) # eols = \5
+ (?= \n* (\Z | \2 (?P<next_marker>%s) [ \t]+))
+ ''' % (_marker_any, _marker_any),
+ re.M | re.X | re.S)
+
+ _last_li_endswith_two_eols = False
+ def _list_item_sub(self, match):
+ item = match.group(4)
+ leading_line = match.group(1)
+ leading_space = match.group(2)
+ if leading_line or "\n\n" in item or self._last_li_endswith_two_eols:
+ item = self._run_block_gamut(self._outdent(item))
+ else:
+ # Recursion for sub-lists:
+ item = self._do_lists(self._outdent(item))
+ if item.endswith('\n'):
+ item = item[:-1]
+ item = self._run_span_gamut(item)
+ self._last_li_endswith_two_eols = (len(match.group(5)) == 2)
+ return "<li>%s</li>\n" % item
+
+ def _process_list_items(self, list_str):
+ # Process the contents of a single ordered or unordered list,
+ # splitting it into individual list items.
+
+ # The $g_list_level global keeps track of when we're inside a list.
+ # Each time we enter a list, we increment it; when we leave a list,
+ # we decrement. If it's zero, we're not in a list anymore.
+ #
+ # We do this because when we're not inside a list, we want to treat
+ # something like this:
+ #
+ # I recommend upgrading to version
+ # 8. Oops, now this line is treated
+ # as a sub-list.
+ #
+ # As a single paragraph, despite the fact that the second line starts
+ # with a digit-period-space sequence.
+ #
+ # Whereas when we're inside a list (or sub-list), that line will be
+ # treated as the start of a sub-list. What a kludge, huh? This is
+ # an aspect of Markdown's syntax that's hard to parse perfectly
+ # without resorting to mind-reading. Perhaps the solution is to
+ # change the syntax rules such that sub-lists must start with a
+ # starting cardinal number; e.g. "1." or "a.".
+ self.list_level += 1
+ self._last_li_endswith_two_eols = False
+ list_str = list_str.rstrip('\n') + '\n'
+ list_str = self._list_item_re.sub(self._list_item_sub, list_str)
+ self.list_level -= 1
+ return list_str
+
+ def _get_pygments_lexer(self, lexer_name):
+ try:
+ from pygments import lexers, util
+ except ImportError:
+ return None
+ try:
+ return lexers.get_lexer_by_name(lexer_name)
+ except util.ClassNotFound:
+ return None
+
+ def _color_with_pygments(self, codeblock, lexer, **formatter_opts):
+ import pygments
+ import pygments.formatters
+
+ class HtmlCodeFormatter(pygments.formatters.HtmlFormatter):
+ def _wrap_code(self, inner):
+ """A function for use in a Pygments Formatter which
+ wraps in <code> tags.
+ """
+ yield 0, "<code>"
+ for tup in inner:
+ yield tup
+ yield 0, "</code>"
+
+ def wrap(self, source, outfile):
+ """Return the source with a code, pre, and div."""
+ return self._wrap_div(self._wrap_pre(self._wrap_code(source)))
+
+ formatter = HtmlCodeFormatter(cssclass="codehilite", **formatter_opts)
+ return pygments.highlight(codeblock, lexer, formatter)
+
+ def _code_block_sub(self, match):
+ codeblock = match.group(1)
+ codeblock = self._outdent(codeblock)
+ codeblock = self._detab(codeblock)
+ codeblock = codeblock.lstrip('\n') # trim leading newlines
+ codeblock = codeblock.rstrip() # trim trailing whitespace
+
+ if "code-color" in self.extras and codeblock.startswith(":::"):
+ lexer_name, rest = codeblock.split('\n', 1)
+ lexer_name = lexer_name[3:].strip()
+ lexer = self._get_pygments_lexer(lexer_name)
+ codeblock = rest.lstrip("\n") # Remove lexer declaration line.
+ if lexer:
+ formatter_opts = self.extras['code-color'] or {}
+ colored = self._color_with_pygments(codeblock, lexer,
+ **formatter_opts)
+ return "\n\n%s\n\n" % colored
+
+ codeblock = self._encode_code(codeblock)
+ pre_class_str = self._html_class_str_from_tag("pre")
+ code_class_str = self._html_class_str_from_tag("code")
+ return "\n\n<pre%s><code%s>%s\n</code></pre>\n\n" % (
+ pre_class_str, code_class_str, codeblock)
+
+ def _html_class_str_from_tag(self, tag):
+ """Get the appropriate ' class="..."' string (note the leading
+ space), if any, for the given tag.
+ """
+ if "html-classes" not in self.extras:
+ return ""
+ try:
+ html_classes_from_tag = self.extras["html-classes"]
+ except TypeError:
+ return ""
+ else:
+ if tag in html_classes_from_tag:
+ return ' class="%s"' % html_classes_from_tag[tag]
+ return ""
+
+ def _do_code_blocks(self, text):
+ """Process Markdown `<pre><code>` blocks."""
+ code_block_re = re.compile(r'''
+ (?:\n\n|\A)
+ ( # $1 = the code block -- one or more lines, starting with a space/tab
+ (?:
+ (?:[ ]{%d} | \t) # Lines must start with a tab or a tab-width of spaces
+ .*\n+
+ )+
+ )
+ ((?=^[ ]{0,%d}\S)|\Z) # Lookahead for non-space at line-start, or end of doc
+ ''' % (self.tab_width, self.tab_width),
+ re.M | re.X)
+
+ return code_block_re.sub(self._code_block_sub, text)
+
+
+ # Rules for a code span:
+ # - backslash escapes are not interpreted in a code span
+ # - to include one or or a run of more backticks the delimiters must
+ # be a longer run of backticks
+ # - cannot start or end a code span with a backtick; pad with a
+ # space and that space will be removed in the emitted HTML
+ # See `test/tm-cases/escapes.text` for a number of edge-case
+ # examples.
+ _code_span_re = re.compile(r'''
+ (?<!\\)
+ (`+) # \1 = Opening run of `
+ (?!`) # See Note A test/tm-cases/escapes.text
+ (.+?) # \2 = The code block
+ (?<!`)
+ \1 # Matching closer
+ (?!`)
+ ''', re.X | re.S)
+
+ def _code_span_sub(self, match):
+ c = match.group(2).strip(" \t")
+ c = self._encode_code(c)
+ return "<code>%s</code>" % c
+
+ def _do_code_spans(self, text):
+ # * Backtick quotes are used for <code></code> spans.
+ #
+ # * You can use multiple backticks as the delimiters if you want to
+ # include literal backticks in the code span. So, this input:
+ #
+ # Just type ``foo `bar` baz`` at the prompt.
+ #
+ # Will translate to:
+ #
+ # <p>Just type <code>foo `bar` baz</code> at the prompt.</p>
+ #
+ # There's no arbitrary limit to the number of backticks you
+ # can use as delimters. If you need three consecutive backticks
+ # in your code, use four for delimiters, etc.
+ #
+ # * You can use spaces to get literal backticks at the edges:
+ #
+ # ... type `` `bar` `` ...
+ #
+ # Turns to:
+ #
+ # ... type <code>`bar`</code> ...
+ return self._code_span_re.sub(self._code_span_sub, text)
+
+ def _encode_code(self, text):
+ """Encode/escape certain characters inside Markdown code runs.
+ The point is that in code, these characters are literals,
+ and lose their special Markdown meanings.
+ """
+ replacements = [
+ # Encode all ampersands; HTML entities are not
+ # entities within a Markdown code span.
+ ('&', '&amp;'),
+ # Do the angle bracket song and dance:
+ ('<', '&lt;'),
+ ('>', '&gt;'),
+ # Now, escape characters that are magic in Markdown:
+ ('*', g_escape_table['*']),
+ ('_', g_escape_table['_']),
+ ('{', g_escape_table['{']),
+ ('}', g_escape_table['}']),
+ ('[', g_escape_table['[']),
+ (']', g_escape_table[']']),
+ ('\\', g_escape_table['\\']),
+ ]
+ for before, after in replacements:
+ text = text.replace(before, after)
+ return text
+
+ _strong_re = re.compile(r"(\*\*|__)(?=\S)(.+?[*_]*)(?<=\S)\1", re.S)
+ _em_re = re.compile(r"(\*|_)(?=\S)(.+?)(?<=\S)\1", re.S)
+ #_spoiler_re = re.compile(r"###(?=\S)(.+?[*_]*)(?<=\S)###", re.S)
+
+ _code_friendly_strong_re = re.compile(r"\*\*(?=\S)(.+?[*_]*)(?<=\S)\*\*", re.S)
+ _code_friendly_em_re = re.compile(r"\*(?=\S)(.+?)(?<=\S)\*", re.S)
+ def _do_italics_and_bold(self, text):
+ # <strong> must go first:
+ if "code-friendly" in self.extras:
+ text = self._code_friendly_strong_re.sub(r"<strong>\1</strong>", text)
+ text = self._code_friendly_em_re.sub(r"<em>\1</em>", text)
+ else:
+ text = self._strong_re.sub(r"<strong>\2</strong>", text)
+ text = self._em_re.sub(r"<em>\2</em>", text)
+
+ #text = self._spoiler_re.sub("<del>\\1</del>", text)
+ return text
+
+
+ _block_quote_re = re.compile(r'''
+ ( # Wrap whole match in \1
+ (
+ ^[ \t]*>[^>] # '>' at the start of a line
+ .+\n # rest of the first line
+ \n* # blanks
+ )+
+ )
+ ''', re.M | re.X)
+ _bq_one_level_re = re.compile('^[ \t]*>[ \t]?', re.M);
+
+ _html_pre_block_re = re.compile(r'(\s*<pre>.+?</pre>)', re.S)
+ def _dedent_two_spaces_sub(self, match):
+ return re.sub(r'(?m)^ ', '', match.group(1))
+
+ def _block_quote_sub(self, match):
+ bq = match.group(1)
+ #bq = self._bq_one_level_re.sub('', bq) # trim one level of quoting
+ bq = self._ws_only_line_re.sub('', bq) # trim whitespace-only lines
+ bq = bq.strip('\n')
+ bq = self._run_span_gamut(bq)
+ #bq = self._run_block_gamut(bq) # recurse
+
+ bq = re.sub('(?m)^', ' ', bq)
+ # These leading spaces screw with <pre> content, so we need to fix that:
+ bq = self._html_pre_block_re.sub(self._dedent_two_spaces_sub, bq)
+
+ return "<blockquote>\n%s\n</blockquote>\n\n" % bq
+
+ def _do_block_quotes(self, text):
+ if '>' not in text:
+ return text
+ return self._block_quote_re.sub(self._block_quote_sub, text)
+
+ def _form_paragraphs(self, text):
+ # Strip leading and trailing lines:
+ text = text.strip('\n')
+
+ # Wrap <p> tags.
+ grafs = []
+ for i, graf in enumerate(re.split(r"\n{2,}", text)):
+ if graf in self.html_blocks:
+ # Unhashify HTML blocks
+ grafs.append(self.html_blocks[graf])
+ else:
+ cuddled_list = None
+ if "cuddled-lists" in self.extras:
+ # Need to put back trailing '\n' for `_list_item_re`
+ # match at the end of the paragraph.
+ li = self._list_item_re.search(graf + '\n')
+ # Two of the same list marker in this paragraph: a likely
+ # candidate for a list cuddled to preceding paragraph
+ # text (issue 33). Note the `[-1]` is a quick way to
+ # consider numeric bullets (e.g. "1." and "2.") to be
+ # equal.
+ if (li and len(li.group(2)) <= 3 and li.group("next_marker")
+ and li.group("marker")[-1] == li.group("next_marker")[-1]):
+ start = li.start()
+ cuddled_list = self._do_lists(graf[start:]).rstrip("\n")
+ assert cuddled_list.startswith("<ul>") or cuddled_list.startswith("<ol>")
+ graf = graf[:start]
+
+ # Wrap <p> tags.
+ graf = self._run_span_gamut(graf)
+ grafs.append("<p>" + graf.lstrip(" \t") + "</p>")
+
+ if cuddled_list:
+ grafs.append(cuddled_list)
+
+ return "\n\n".join(grafs)
+
+ def _add_footnotes(self, text):
+ if self.footnotes:
+ footer = [
+ '<div class="footnotes">',
+ '<hr' + self.empty_element_suffix,
+ '<ol>',
+ ]
+ for i, id in enumerate(self.footnote_ids):
+ if i != 0:
+ footer.append('')
+ footer.append('<li id="fn-%s">' % id)
+ footer.append(self._run_block_gamut(self.footnotes[id]))
+ backlink = ('<a href="#fnref-%s" '
+ 'class="footnoteBackLink" '
+ 'title="Jump back to footnote %d in the text.">'
+ '&#8617;</a>' % (id, i+1))
+ if footer[-1].endswith("</p>"):
+ footer[-1] = footer[-1][:-len("</p>")] \
+ + '&nbsp;' + backlink + "</p>"
+ else:
+ footer.append("\n<p>%s</p>" % backlink)
+ footer.append('</li>')
+ footer.append('</ol>')
+ footer.append('</div>')
+ return text + '\n\n' + '\n'.join(footer)
+ else:
+ return text
+
+ # Ampersand-encoding based entirely on Nat Irons's Amputator MT plugin:
+ # http://bumppo.net/projects/amputator/
+ _ampersand_re = re.compile(r'&(?!#?[xX]?(?:[0-9a-fA-F]+|\w+);)')
+ _naked_lt_re = re.compile(r'<(?![a-z/?\$!])', re.I)
+ _naked_gt_re = re.compile(r'''(?<![a-z?!/'"-])>''', re.I)
+
+ def _encode_amps_and_angles(self, text):
+ # Smart processing for ampersands and angle brackets that need
+ # to be encoded.
+ text = self._ampersand_re.sub('&amp;', text)
+
+ # Encode naked <'s
+ text = self._naked_lt_re.sub('&lt;', text)
+
+ # Encode naked >'s
+ # Note: Other markdown implementations (e.g. Markdown.pl, PHP
+ # Markdown) don't do this.
+ text = self._naked_gt_re.sub('&gt;', text)
+ return text
+
+ def _encode_backslash_escapes(self, text):
+ for ch, escape in g_escape_table.items():
+ text = text.replace("\\"+ch, escape)
+ return text
+
+ _auto_link_re = re.compile(r'<((https?|ftp):[^\'">\s]+)>', re.I)
+ def _auto_link_sub(self, match):
+ g1 = match.group(1)
+ return '<a href="%s">%s</a>' % (g1, g1)
+
+ _auto_email_link_re = re.compile(r"""
+ <
+ (?:mailto:)?
+ (
+ [-.\w]+
+ \@
+ [-\w]+(\.[-\w]+)*\.[a-z]+
+ )
+ >
+ """, re.I | re.X | re.U)
+ def _auto_email_link_sub(self, match):
+ return self._encode_email_address(
+ self._unescape_special_chars(match.group(1)))
+
+ def _do_auto_links(self, text):
+ text = self._auto_link_re.sub(self._auto_link_sub, text)
+ text = self._auto_email_link_re.sub(self._auto_email_link_sub, text)
+ return text
+
+ def _encode_email_address(self, addr):
+ # Input: an email address, e.g. "foo@example.com"
+ #
+ # Output: the email address as a mailto link, with each character
+ # of the address encoded as either a decimal or hex entity, in
+ # the hopes of foiling most address harvesting spam bots. E.g.:
+ #
+ # <a href="&#x6D;&#97;&#105;&#108;&#x74;&#111;:&#102;&#111;&#111;&#64;&#101;
+ # x&#x61;&#109;&#x70;&#108;&#x65;&#x2E;&#99;&#111;&#109;">&#102;&#111;&#111;
+ # &#64;&#101;x&#x61;&#109;&#x70;&#108;&#x65;&#x2E;&#99;&#111;&#109;</a>
+ #
+ # Based on a filter by Matthew Wickline, posted to the BBEdit-Talk
+ # mailing list: <http://tinyurl.com/yu7ue>
+ chars = [_xml_encode_email_char_at_random(ch)
+ for ch in "mailto:" + addr]
+ # Strip the mailto: from the visible part.
+ addr = '<a href="%s">%s</a>' \
+ % (''.join(chars), ''.join(chars[7:]))
+ return addr
+
+ def _do_link_patterns(self, text):
+ """Caveat emptor: there isn't much guarding against link
+ patterns being formed inside other standard Markdown links, e.g.
+ inside a [link def][like this].
+
+ Dev Notes: *Could* consider prefixing regexes with a negative
+ lookbehind assertion to attempt to guard against this.
+ """
+ link_from_hash = {}
+ for regex, repl in self.link_patterns:
+ replacements = []
+ for match in regex.finditer(text):
+ if hasattr(repl, "__call__"):
+ href = repl(match)
+ else:
+ href = match.expand(repl)
+ replacements.append((match.span(), href))
+ for (start, end), href in reversed(replacements):
+ escaped_href = (
+ href.replace('"', '&quot;') # b/c of attr quote
+ # To avoid markdown <em> and <strong>:
+ .replace('*', g_escape_table['*'])
+ .replace('_', g_escape_table['_']))
+ link = '<a href="%s">%s</a>' % (escaped_href, text[start:end])
+ hash = _hash_text(link)
+ link_from_hash[hash] = link
+ text = text[:start] + hash + text[end:]
+ for hash, link in link_from_hash.items():
+ text = text.replace(hash, link)
+ return text
+
+ def _unescape_special_chars(self, text):
+ # Swap back in all the special characters we've hidden.
+ for ch, hash in g_escape_table.items():
+ text = text.replace(hash, ch)
+ return text
+
+ def _outdent(self, text):
+ # Remove one level of line-leading tabs or spaces
+ return self._outdent_re.sub('', text)
+
+
+class MarkdownWithExtras(Markdown):
+ """A markdowner class that enables most extras:
+
+ - footnotes
+ - code-color (only has effect if 'pygments' Python module on path)
+
+ These are not included:
+ - pyshell (specific to Python-related documenting)
+ - code-friendly (because it *disables* part of the syntax)
+ - link-patterns (because you need to specify some actual
+ link-patterns anyway)
+ """
+ extras = ["footnotes", "code-color"]
+
+
+#---- internal support functions
+
+class UnicodeWithAttrs(unicode):
+ """A subclass of unicode used for the return value of conversion to
+ possibly attach some attributes. E.g. the "toc_html" attribute when
+ the "toc" extra is used.
+ """
+ _toc = None
+ @property
+ def toc_html(self):
+ """Return the HTML for the current TOC.
+
+ This expects the `_toc` attribute to have been set on this instance.
+ """
+ if self._toc is None:
+ return None
+
+ def indent():
+ return ' ' * (len(h_stack) - 1)
+ lines = []
+ h_stack = [0] # stack of header-level numbers
+ for level, id, name in self._toc:
+ if level > h_stack[-1]:
+ lines.append("%s<ul>" % indent())
+ h_stack.append(level)
+ elif level == h_stack[-1]:
+ lines[-1] += "</li>"
+ else:
+ while level < h_stack[-1]:
+ h_stack.pop()
+ if not lines[-1].endswith("</li>"):
+ lines[-1] += "</li>"
+ lines.append("%s</ul></li>" % indent())
+ lines.append(u'%s<li><a href="#%s">%s</a>' % (
+ indent(), id, name))
+ while len(h_stack) > 1:
+ h_stack.pop()
+ if not lines[-1].endswith("</li>"):
+ lines[-1] += "</li>"
+ lines.append("%s</ul>" % indent())
+ return '\n'.join(lines) + '\n'
+
+
+_slugify_strip_re = re.compile(r'[^\w\s-]')
+_slugify_hyphenate_re = re.compile(r'[-\s]+')
+def _slugify(value):
+ """
+ Normalizes string, converts to lowercase, removes non-alpha characters,
+ and converts spaces to hyphens.
+
+ From Django's "django/template/defaultfilters.py".
+ """
+ import unicodedata
+ value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore')
+ value = unicode(_slugify_strip_re.sub('', value).strip().lower())
+ return _slugify_hyphenate_re.sub('-', value)
+
+# From http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/52549
+def _curry(*args, **kwargs):
+ function, args = args[0], args[1:]
+ def result(*rest, **kwrest):
+ combined = kwargs.copy()
+ combined.update(kwrest)
+ return function(*args + rest, **combined)
+ return result
+
+# Recipe: regex_from_encoded_pattern (1.0)
+def _regex_from_encoded_pattern(s):
+ """'foo' -> re.compile(re.escape('foo'))
+ '/foo/' -> re.compile('foo')
+ '/foo/i' -> re.compile('foo', re.I)
+ """
+ if s.startswith('/') and s.rfind('/') != 0:
+ # Parse it: /PATTERN/FLAGS
+ idx = s.rfind('/')
+ pattern, flags_str = s[1:idx], s[idx+1:]
+ flag_from_char = {
+ "i": re.IGNORECASE,
+ "l": re.LOCALE,
+ "s": re.DOTALL,
+ "m": re.MULTILINE,
+ "u": re.UNICODE,
+ }
+ flags = 0
+ for char in flags_str:
+ try:
+ flags |= flag_from_char[char]
+ except KeyError:
+ raise ValueError("unsupported regex flag: '%s' in '%s' "
+ "(must be one of '%s')"
+ % (char, s, ''.join(flag_from_char.keys())))
+ return re.compile(s[1:idx], flags)
+ else: # not an encoded regex
+ return re.compile(re.escape(s))
+
+# Recipe: dedent (0.1.2)
+def _dedentlines(lines, tabsize=8, skip_first_line=False):
+ """_dedentlines(lines, tabsize=8, skip_first_line=False) -> dedented lines
+
+ "lines" is a list of lines to dedent.
+ "tabsize" is the tab width to use for indent width calculations.
+ "skip_first_line" is a boolean indicating if the first line should
+ be skipped for calculating the indent width and for dedenting.
+ This is sometimes useful for docstrings and similar.
+
+ Same as dedent() except operates on a sequence of lines. Note: the
+ lines list is modified **in-place**.
+ """
+ DEBUG = False
+ if DEBUG:
+ print "dedent: dedent(..., tabsize=%d, skip_first_line=%r)"\
+ % (tabsize, skip_first_line)
+ indents = []
+ margin = None
+ for i, line in enumerate(lines):
+ if i == 0 and skip_first_line: continue
+ indent = 0
+ for ch in line:
+ if ch == ' ':
+ indent += 1
+ elif ch == '\t':
+ indent += tabsize - (indent % tabsize)
+ elif ch in '\r\n':
+ continue # skip all-whitespace lines
+ else:
+ break
+ else:
+ continue # skip all-whitespace lines
+ if DEBUG: print "dedent: indent=%d: %r" % (indent, line)
+ if margin is None:
+ margin = indent
+ else:
+ margin = min(margin, indent)
+ if DEBUG: print "dedent: margin=%r" % margin
+
+ if margin is not None and margin > 0:
+ for i, line in enumerate(lines):
+ if i == 0 and skip_first_line: continue
+ removed = 0
+ for j, ch in enumerate(line):
+ if ch == ' ':
+ removed += 1
+ elif ch == '\t':
+ removed += tabsize - (removed % tabsize)
+ elif ch in '\r\n':
+ if DEBUG: print "dedent: %r: EOL -> strip up to EOL" % line
+ lines[i] = lines[i][j:]
+ break
+ else:
+ raise ValueError("unexpected non-whitespace char %r in "
+ "line %r while removing %d-space margin"
+ % (ch, line, margin))
+ if DEBUG:
+ print "dedent: %r: %r -> removed %d/%d"\
+ % (line, ch, removed, margin)
+ if removed == margin:
+ lines[i] = lines[i][j+1:]
+ break
+ elif removed > margin:
+ lines[i] = ' '*(removed-margin) + lines[i][j+1:]
+ break
+ else:
+ if removed:
+ lines[i] = lines[i][removed:]
+ return lines
+
+def _dedent(text, tabsize=8, skip_first_line=False):
+ """_dedent(text, tabsize=8, skip_first_line=False) -> dedented text
+
+ "text" is the text to dedent.
+ "tabsize" is the tab width to use for indent width calculations.
+ "skip_first_line" is a boolean indicating if the first line should
+ be skipped for calculating the indent width and for dedenting.
+ This is sometimes useful for docstrings and similar.
+
+ textwrap.dedent(s), but don't expand tabs to spaces
+ """
+ lines = text.splitlines(1)
+ _dedentlines(lines, tabsize=tabsize, skip_first_line=skip_first_line)
+ return ''.join(lines)
+
+
+class _memoized(object):
+ """Decorator that caches a function's return value each time it is called.
+ If called later with the same arguments, the cached value is returned, and
+ not re-evaluated.
+
+ http://wiki.python.org/moin/PythonDecoratorLibrary
+ """
+ def __init__(self, func):
+ self.func = func
+ self.cache = {}
+ def __call__(self, *args):
+ try:
+ return self.cache[args]
+ except KeyError:
+ self.cache[args] = value = self.func(*args)
+ return value
+ except TypeError:
+ # uncachable -- for instance, passing a list as an argument.
+ # Better to not cache than to blow up entirely.
+ return self.func(*args)
+ def __repr__(self):
+ """Return the function's docstring."""
+ return self.func.__doc__
+
+
+def _xml_oneliner_re_from_tab_width(tab_width):
+ """Standalone XML processing instruction regex."""
+ return re.compile(r"""
+ (?:
+ (?<=\n\n) # Starting after a blank line
+ | # or
+ \A\n? # the beginning of the doc
+ )
+ ( # save in $1
+ [ ]{0,%d}
+ (?:
+ <\?\w+\b\s+.*?\?> # XML processing instruction
+ |
+ <\w+:\w+\b\s+.*?/> # namespaced single tag
+ )
+ [ \t]*
+ (?=\n{2,}|\Z) # followed by a blank line or end of document
+ )
+ """ % (tab_width - 1), re.X)
+_xml_oneliner_re_from_tab_width = _memoized(_xml_oneliner_re_from_tab_width)
+
+def _hr_tag_re_from_tab_width(tab_width):
+ return re.compile(r"""
+ (?:
+ (?<=\n\n) # Starting after a blank line
+ | # or
+ \A\n? # the beginning of the doc
+ )
+ ( # save in \1
+ [ ]{0,%d}
+ <(hr) # start tag = \2
+ \b # word break
+ ([^<>])*? #
+ /?> # the matching end tag
+ [ \t]*
+ (?=\n{2,}|\Z) # followed by a blank line or end of document
+ )
+ """ % (tab_width - 1), re.X)
+_hr_tag_re_from_tab_width = _memoized(_hr_tag_re_from_tab_width)
+
+
+def _xml_encode_email_char_at_random(ch):
+ r = random()
+ # Roughly 10% raw, 45% hex, 45% dec.
+ # '@' *must* be encoded. I [John Gruber] insist.
+ # Issue 26: '_' must be encoded.
+ if r > 0.9 and ch not in "@_":
+ return ch
+ elif r < 0.45:
+ # The [1:] is to drop leading '0': 0x63 -> x63
+ return '&#%s;' % hex(ord(ch))[1:]
+ else:
+ return '&#%s;' % ord(ch)
+
+
+
+#---- mainline
+
+class _NoReflowFormatter(optparse.IndentedHelpFormatter):
+ """An optparse formatter that does NOT reflow the description."""
+ def format_description(self, description):
+ return description or ""
+
+def _test():
+ import doctest
+ doctest.testmod()
+
+def main(argv=None):
+ if argv is None:
+ argv = sys.argv
+ if not logging.root.handlers:
+ logging.basicConfig()
+
+ usage = "usage: %prog [PATHS...]"
+ version = "%prog "+__version__
+ parser = optparse.OptionParser(prog="markdown2", usage=usage,
+ version=version, description=cmdln_desc,
+ formatter=_NoReflowFormatter())
+ parser.add_option("-v", "--verbose", dest="log_level",
+ action="store_const", const=logging.DEBUG,
+ help="more verbose output")
+ parser.add_option("--encoding",
+ help="specify encoding of text content")
+ parser.add_option("--html4tags", action="store_true", default=False,
+ help="use HTML 4 style for empty element tags")
+ parser.add_option("-s", "--safe", metavar="MODE", dest="safe_mode",
+ help="sanitize literal HTML: 'escape' escapes "
+ "HTML meta chars, 'replace' replaces with an "
+ "[HTML_REMOVED] note")
+ parser.add_option("-x", "--extras", action="append",
+ help="Turn on specific extra features (not part of "
+ "the core Markdown spec). See above.")
+ parser.add_option("--use-file-vars",
+ help="Look for and use Emacs-style 'markdown-extras' "
+ "file var to turn on extras. See "
+ "<http://code.google.com/p/python-markdown2/wiki/Extras>.")
+ parser.add_option("--link-patterns-file",
+ help="path to a link pattern file")
+ parser.add_option("--self-test", action="store_true",
+ help="run internal self-tests (some doctests)")
+ parser.add_option("--compare", action="store_true",
+ help="run against Markdown.pl as well (for testing)")
+ parser.set_defaults(log_level=logging.INFO, compare=False,
+ encoding="utf-8", safe_mode=None, use_file_vars=False)
+ opts, paths = parser.parse_args()
+ log.setLevel(opts.log_level)
+
+ if opts.self_test:
+ return _test()
+
+ if opts.extras:
+ extras = {}
+ for s in opts.extras:
+ splitter = re.compile("[,;: ]+")
+ for e in splitter.split(s):
+ if '=' in e:
+ ename, earg = e.split('=', 1)
+ try:
+ earg = int(earg)
+ except ValueError:
+ pass
+ else:
+ ename, earg = e, None
+ extras[ename] = earg
+ else:
+ extras = None
+
+ if opts.link_patterns_file:
+ link_patterns = []
+ f = open(opts.link_patterns_file)
+ try:
+ for i, line in enumerate(f.readlines()):
+ if not line.strip(): continue
+ if line.lstrip().startswith("#"): continue
+ try:
+ pat, href = line.rstrip().rsplit(None, 1)
+ except ValueError:
+ raise MarkdownError("%s:%d: invalid link pattern line: %r"
+ % (opts.link_patterns_file, i+1, line))
+ link_patterns.append(
+ (_regex_from_encoded_pattern(pat), href))
+ finally:
+ f.close()
+ else:
+ link_patterns = None
+
+ from os.path import join, dirname, abspath, exists
+ markdown_pl = join(dirname(dirname(abspath(__file__))), "test",
+ "Markdown.pl")
+ for path in paths:
+ if opts.compare:
+ print "==== Markdown.pl ===="
+ perl_cmd = 'perl %s "%s"' % (markdown_pl, path)
+ o = os.popen(perl_cmd)
+ perl_html = o.read()
+ o.close()
+ sys.stdout.write(perl_html)
+ print "==== markdown2.py ===="
+ html = markdown_path(path, encoding=opts.encoding,
+ html4tags=opts.html4tags,
+ safe_mode=opts.safe_mode,
+ extras=extras, link_patterns=link_patterns,
+ use_file_vars=opts.use_file_vars)
+ sys.stdout.write(
+ html.encode(sys.stdout.encoding or "utf-8", 'xmlcharrefreplace'))
+ if extras and "toc" in extras:
+ log.debug("toc_html: " +
+ html.toc_html.encode(sys.stdout.encoding or "utf-8", 'xmlcharrefreplace'))
+ if opts.compare:
+ test_dir = join(dirname(dirname(abspath(__file__))), "test")
+ if exists(join(test_dir, "test_markdown2.py")):
+ sys.path.insert(0, test_dir)
+ from test_markdown2 import norm_html_from_html
+ norm_html = norm_html_from_html(html)
+ norm_perl_html = norm_html_from_html(perl_html)
+ else:
+ norm_html = html
+ norm_perl_html = perl_html
+ print "==== match? %r ====" % (norm_perl_html == norm_html)
+
+
+if __name__ == "__main__":
+ sys.exit( main(sys.argv) )
+
diff --git a/cgi/oekaki.py b/cgi/oekaki.py
new file mode 100644
index 0000000..f0bada7
--- /dev/null
+++ b/cgi/oekaki.py
@@ -0,0 +1,176 @@
+# coding=utf-8
+import _mysql
+import os
+import cgi
+import random
+
+from database import *
+from settings import Settings
+from framework import *
+from formatting import *
+from template import *
+from post import *
+
+def oekaki(self, path_split):
+ """
+ Este script hace todo lo que tiene que hacer con los
+ archivos de Oekaki.
+ """
+ page = ''
+ skiptemplate = False
+
+ if len(path_split) > 2:
+ # Inicia el applet. Lo envia luego a este mismo script, a "Finish".
+ if path_split[2] == 'paint':
+ # Veamos que applet usar
+ applet = self.formdata['oek_applet'].split('|')
+
+ applet_name = applet[0]
+
+ if len(applet) > 1 and applet[1] == 'y':
+ applet_str = 'pro'
+ else:
+ applet_str = ''
+
+ if len(applet) > 2 and applet[2] == 'y':
+ use_selfy = True
+ else:
+ use_selfy = False
+
+ # Obtenemos el board
+ board = setBoard(self.formdata['board'])
+
+ if board['allow_oekaki'] != '1':
+ raise UserError, 'Esta sección no soporta oekaki.'
+
+ # Veamos a quien le estamos respondiendo
+ try:
+ parentid = int(self.formdata['parent'])
+ except:
+ parentid = 0
+
+ # Vemos si el usuario quiere una animacion
+ if 'oek_animation' in self.formdata.keys():
+ animation = True
+ animation_str = 'animation'
+ else:
+ animation = False
+ animation_str = ''
+
+ # Nos aseguramos que la entrada es numerica
+ try:
+ width = int(self.formdata['oek_x'])
+ height = int(self.formdata['oek_y'])
+ except:
+ raise UserError, 'Valores de tamaño inválidos (%s)' % repr(self.formdata)
+
+ params = {
+ 'dir_resource': Settings.BOARDS_URL + 'oek_temp/',
+ 'tt.zip': 'tt_def.zip',
+ 'res.zip': 'res.zip',
+ 'MAYSCRIPT': 'true',
+ 'scriptable': 'true',
+ 'tools': applet_str,
+ 'layer_count': '5',
+ 'undo': '90',
+ 'undo_in_mg': '15',
+ 'url_save': Settings.BOARDS_URL + 'oek_temp/save.php?applet=shi'+applet_str,
+ 'poo': 'false',
+ 'send_advance': 'true',
+ 'send_language': 'utf8',
+ 'send_header': '',
+ 'send_header_image_type': 'false',
+ 'thumbnail_type': animation_str,
+ 'image_jpeg': 'false',
+ 'image_size': '92',
+ 'compress_level': '4'
+ }
+
+ if 'oek_edit' in self.formdata.keys():
+ # Si hay que editar, cargar la imagen correspondiente en el canvas
+ pid = int(self.formdata['oek_edit'])
+ post = FetchOne('SELECT id, file, image_width, image_height FROM posts WHERE id = %d AND boardid = %s' % (pid, board['id']))
+ editfile = Settings.BOARDS_URL + board['dir'] + '/src/' + post['file']
+
+ params['image_canvas'] = edit
+ params['image_width'] = file['image_width']
+ params['image_height'] = file['image_height']
+ width = int(file['image_width'])
+ height = int(file['image_height'])
+ else:
+ editfile = None
+ params['image_width'] = str(width)
+ params['image_height'] = str(height)
+
+ if 'canvas' in self.formdata.keys():
+ editfile = self.formdata['canvas']
+
+ # Darle las dimensiones al exit script
+ params['url_exit'] = Settings.CGI_URL + 'oekaki/finish/' + board['dir'] + '/' + str(parentid)
+
+ page += renderTemplate("paint.html", {'applet': applet_name, 'edit': editfile, 'replythread': parentid, 'width': width, 'height': height, 'params': params, 'selfy': use_selfy})
+ elif path_split[2] == 'finish':
+ # path splits:
+ # 3: Board
+ # 4: Parentid
+ if path_split > 7:
+ # Al terminar de dibujar, llegamos aqui. Damos la opcion de postearlo.
+ board = setBoard(path_split[3])
+ try:
+ parentid = int(path_split[4])
+ except:
+ parentid = None
+
+ ts = int(time.time())
+ ip = inet_aton(self.environ["REMOTE_ADDR"])
+ fname = "%s/oek_temp/%d.png" % (Settings.HOME_DIR, ip)
+ oek = 'no'
+
+ if 'filebase' in self.formdata:
+ img = self.formdata['filebase']
+ if img.startswith("data:image/png;base64,"):
+ img = img[22:]
+ img = img.replace(' ', '+')
+ img = img.decode('base64')
+ with open(fname, 'wb') as f:
+ f.write(img)
+
+ if os.path.isfile(fname):
+ oek = ip
+
+ try:
+ timetaken = timestamp() - int(path_split[5][:-2])
+ except:
+ timetaken = 0
+
+ page += renderTemplate("board.html", {"threads": None, "oek_finish": oek, "replythread": parentid, "ts": ts})
+
+ elif path_split[2] == 'animation':
+ try:
+ board = setBoard(path_split[3])
+ file = int(path_split[4])
+ except:
+ raise UserError, 'Board o archivo de animación inválido.'
+
+ params = {
+ 'pch_file': Settings.BOARDS_URL + board['dir'] + '/src/' + str(file) + '.pch',
+ 'run': 'true',
+ 'buffer_progress': 'false',
+ 'buffer_canvas': 'true',
+ 'speed': '2',
+ 'res.zip': Settings.BOARDS_URL + 'oek_temp/res/' +'res.zip',
+ 'tt.zip': Settings.BOARDS_URL + 'oek_temp/res/' + 'tt.zip',
+ 'tt_size': '31'
+ }
+ page += '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">' + \
+ '<html xmlns="http://www.w3.org/1999/xhtml">\n<head><style type="text/css">html, body{margin: 0; padding: 0;height:100%;} .full{width:100%;height:100%;}</style>\n<title>Bienvenido a Internet | Oekaki</title>\n</head>\n' + \
+ '<body bgcolor="#CFCFFF" text="#800000" link="#003399" vlink="#808080" alink="#11FF11">\n' + \
+ '<table cellpadding="0" cellspacing="0" class="full"><tr><td class="full">\n'
+ page += '<applet name="pch" code="pch2.PCHViewer.class" archive="' + Settings.BOARDS_URL + 'oek_temp/PCHViewer123.jar" width="100%" height="100%">'
+ for key in params.keys():
+ page += '<param name="' + key + '" value="' + cleanString(params[key]) + '" />' + "\n"
+ page += '<div align="center">Java must be installed and enabled to use this applet. Please refer to our Java setup tutorial for more information.</div>'
+ page += '</applet>\n</td></tr></table>\n</body>\n</html>'
+
+ if not skiptemplate:
+ self.output = page
diff --git a/cgi/post.py b/cgi/post.py
new file mode 100644
index 0000000..f0ee814
--- /dev/null
+++ b/cgi/post.py
@@ -0,0 +1,1260 @@
+# coding=utf-8
+import math
+import os
+import shutil
+import time
+import threading
+import Queue
+import _mysql
+import formatting
+
+from database import *
+from template import *
+from settings import Settings
+from framework import *
+
+class Post(object):
+ def __init__(self, boardid=0):
+ self.post = {
+ "boardid": boardid,
+ "parentid": 0,
+ "name": "",
+ "tripcode": "",
+ "email": "",
+ "subject": "",
+ "message": "",
+ "password": "",
+ "file": "",
+ "file_hex": "",
+ "file_size": 0,
+ "thumb": "",
+ "image_width": 0,
+ "image_height": 0,
+ "thumb_width": 0,
+ "thumb_height": 0,
+ "ip": "",
+ "timestamp_formatted": "",
+ "timestamp": 0,
+ "bumped": 0,
+ "locked": 0,
+ }
+
+ def __getitem__(self, key):
+ return self.post[key]
+
+ def __setitem__(self, key, value):
+ self.post[key] = value
+
+ def __iter__(self):
+ return self.post
+
+ def insert(self):
+ logTime("Insertando Post")
+ post_values = [_mysql.escape_string(str(value)) for key, value in self.post.iteritems()]
+
+ return InsertDb("INSERT INTO `posts` (`%s`) VALUES ('%s')" % (
+ "`, `".join(self.post.keys()),
+ "', '".join(post_values)
+ ))
+
+class RegenerateThread(threading.Thread):
+ def __init__(self, threadid, request_queue):
+ threading.Thread.__init__(self, name="RegenerateThread-%d" % (threadid,))
+ self.request_queue = request_queue
+ self.board = Settings._.BOARD
+
+ def run(self):
+ Settings._.BOARD = self.board
+ while 1:
+ action = self.request_queue.get()
+ if action is None:
+ break
+ if action == "front":
+ regenerateFrontPages()
+ else:
+ regenerateThreadPage(action)
+
+def threadNumReplies(post):
+ """
+ Get how many replies a thread has
+ """
+ board = Settings._.BOARD
+
+ num = FetchOne("SELECT COUNT(1) FROM `posts` WHERE `parentid` = '%s' AND `boardid` = '%s'" % (post, board['id']), 0)
+ return int(num[0])+1
+
+def get_parent_post(post_id, board_id):
+ post = FetchOne("SELECT `id`, `email`, `message`, `locked`, `subject`, `timestamp`, `bumped`, `last`, `length` FROM `posts` WHERE `id` = %s AND `parentid` = 0 AND `IS_DELETED` = 0 AND `boardid` = %s LIMIT 1" % (post_id, board_id))
+ if post:
+ return post
+ else:
+ raise UserError, _("The ID of the parent post is invalid.")
+
+def getThread(postid=0, mobile=False, timestamp=0):
+ board = Settings._.BOARD
+ total_bytes = 0
+
+ database_lock.acquire()
+ try:
+ if timestamp:
+ cond = "`timestamp` = %s" % str(timestamp)
+ else:
+ cond = "`id` = %s" % str(postid)
+
+ op_post = FetchOne("SELECT IS_DELETED, email, file, file_size, id, image_height, image_width, ip, message, name, subject, thumb, thumb_height, thumb_width, timestamp_formatted, tripcode, parentid, locked, expires, expires_alert, expires_formatted, timestamp FROM `posts` WHERE %s AND `boardid` = %s AND parentid = 0 LIMIT 1" % (cond, board["id"]))
+ if op_post:
+ op_post['num'] = 1
+ if mobile:
+ op_post['timestamp_formatted'] = re.compile(r"\(.{1,3}\)", re.DOTALL | re.IGNORECASE).sub(" ", op_post['timestamp_formatted'])
+ thread = {"id": op_post["id"], "posts": [op_post], "omitted": 0}
+ #thread = {"id": op_post["id"], "posts": [op_post], "omitted": 0, "omitted_img": 0}
+ total_bytes += len(op_post["message"])+80
+
+ replies = FetchAll("SELECT IS_DELETED, email, file, file_size, id, image_height, image_width, ip, message, name, subject, thumb, thumb_height, thumb_width, timestamp_formatted, tripcode, parentid, locked, expires, expires_alert, expires_formatted, timestamp FROM `posts` WHERE `parentid` = %s AND `boardid` = %s ORDER BY `id` ASC" % (op_post["id"], board["id"]))
+ thread["length"] = 1
+ if replies:
+ for reply in replies:
+ thread["length"] += 1
+ reply['num'] = thread["length"]
+ if mobile:
+ reply['message'] = formatting.fixMobileLinks(reply['message'])
+ reply['timestamp_formatted'] = re.compile(r"\(.{1,3}\)", re.DOTALL | re.IGNORECASE).sub(" ", reply['timestamp_formatted'])
+ thread["posts"].append(reply)
+ total_bytes += len(reply["message"])+57
+
+ # An imageboard needs subject
+ if board["board_type"] in ['1', '5']:
+ thread["timestamp"] = op_post["timestamp"]
+ thread["subject"] = op_post["subject"]
+ thread["message"] = op_post["message"]
+ thread["locked"] = op_post["locked"]
+ thread["size"] = "%d KB" % int(total_bytes / 1000)
+
+ #threads = [thread]
+ else:
+ return None
+ finally:
+ database_lock.release()
+
+ return thread
+
+def getID(threadid, postnum):
+ board = Settings._.BOARD
+
+ database_lock.acquire()
+ try:
+ posts = FetchAll("SELECT id FROM `posts` WHERE `parentid`=%s AND `boardid`=%s ORDER BY `id` ASC" % (thread["id"], board["id"]))
+ if posts:
+ post = posts[int(postnum)-1]
+ postid = post["id"]
+ else:
+ return None
+ finally:
+ database_lock.release()
+
+ return postid
+
+def shortenMsg(message, elid='0', elboard='0'):
+ """
+ Intenta acortar el mensaje si es necesario
+ Algoritmo traducido desde KusabaX
+ """
+ board = Settings._.BOARD
+
+ limit = 100 * int(board['numline'])
+
+ message_exploded = message.split('<br />')
+ if len(message) > limit or len(message_exploded) > int(board['numline']):
+ message_shortened = ''
+ for i in range(int(board['numline'])):
+ if i >= len(message_exploded):
+ break
+
+ message_shortened += message_exploded[i] + '<br />'
+
+ #try:
+ message_shortened = message_shortened.decode('utf-8', 'replace')
+ #except:
+
+ if len(message_shortened) > limit:
+ message_shortened = message_shortened[:limit]
+
+ message_shortened = formatting.close_html(message_shortened)
+
+ return True, message_shortened
+ else:
+ return False, message
+
+def threadUpdated(postid):
+ """
+ Shortcut to update front pages and thread page by passing a thread ID. Uses
+ the simple threading module to do both regenerateFrontPages() and
+ regenerateThreadPage() asynchronously
+ """
+ # Use queues only if multithreading is enabled
+ if Settings.USE_MULTITHREADING:
+ request_queue = Queue.Queue()
+ threads = [RegenerateThread(i, request_queue) for i in range(2)]
+ for t in threads:
+ t.start()
+
+ request_queue.put("front")
+ request_queue.put(postid)
+
+ for i in range(2):
+ request_queue.put(None)
+
+ for t in threads:
+ t.join
+ else:
+ regenerateFrontPages()
+ regenerateThreadPage(postid)
+
+def regenerateFrontPages():
+ """
+ Regenerates index.html and #.html for each page after that according to the number
+ of live threads in the database
+ """
+ board = Settings._.BOARD
+ threads = []
+ if board['board_type'] == '1':
+ threads_to_fetch = int(board['numthreads'])
+ threads_to_limit = threads_to_fetch + 50
+ else:
+ if board['dir'] == 'o':
+ threads_to_fetch = threads_to_limit = int(board['numthreads'])*21
+ else:
+ threads_to_fetch = threads_to_limit = int(board['numthreads'])*11
+
+ database_lock.acquire()
+ try:
+ # fetch necessary threads and calculate how many posts we need
+ allthreads_query = "SELECT id, timestamp, subject, locked, length FROM `posts` WHERE `boardid` = '%s' AND parentid = 0 AND IS_DELETED = 0 ORDER BY `bumped` DESC, `id` ASC LIMIT %d" % \
+ (board["id"], threads_to_limit)
+ allthreads = FetchAll(allthreads_query)
+ posts_to_fetch = 0
+ for t in allthreads[:threads_to_fetch]:
+ posts_to_fetch += int(t["length"])
+ more_threads = allthreads[threads_to_fetch:50]
+
+ # get the needed posts for the front page and order them
+ posts_query = "SELECT * FROM `posts` WHERE `boardid` = '%s' ORDER BY `bumped` DESC, CASE parentid WHEN 0 THEN id ELSE parentid END ASC, `id` ASC LIMIT %d" % \
+ (board["id"], posts_to_fetch)
+ posts = FetchAll(posts_query)
+
+ threads = []
+ if posts:
+ thread = None
+ post_num = 0
+
+ for post in posts:
+ if post["parentid"] == '0':
+ skipThread = False
+ if post["IS_DELETED"] == '0':
+ # OP; Make new thread
+ if thread is not None:
+ thread["length"] = post_num
+ threads.append(thread)
+ post_num = post["num"] = 1
+ thread = {"id": post["id"], "timestamp": post["timestamp"], "subject": post["subject"], "locked": post["locked"], "posts": [post]}
+ else:
+ skipThread = True
+ else:
+ if not skipThread:
+ post_num += 1
+ post["num"] = post_num
+ thread["posts"].append(post)
+
+ if post_num:
+ thread["length"] = post_num
+ threads.append(thread)
+ finally:
+ database_lock.release()
+
+ pages = []
+ is_omitted = False
+ if len(threads) > 0:
+ # Todo : Make this better
+ if board['board_type'] == '1':
+ page_count = 1 # Front page only
+ threads_per_page = int(board['numthreads'])
+ else:
+ if board['dir'] == 'o':
+ front_limit = int(board['numthreads'])*21
+ else:
+ front_limit = int(board['numthreads'])*11
+
+ if len(threads) >= front_limit:
+ is_omitted = True
+
+ page_count = int(math.ceil(float(len(threads)) / float(int(board['numthreads']))))
+ threads_per_page = int(board['numthreads'])
+
+ for i in xrange(page_count):
+ pages.append([])
+ start = i * threads_per_page
+ end = start + threads_per_page
+ for thread in threads[start:end]:
+ pages[i].append(thread)
+ else:
+ page_count = 0
+ is_omitted = False
+ pages.append({})
+
+ page_num = 0
+ for pagethreads in pages:
+ regeneratePage(page_num, page_count, pagethreads, is_omitted, more_threads)
+ page_num += 1
+
+def regeneratePage(page_num, page_count, threads, is_omitted=False, more_threads=[]):
+ """
+ Regenerates a single page and writes it to .html
+ """
+ board = Settings._.BOARD
+
+ for thread in threads:
+ replylimit = int(board['numcont'])
+
+ # Create reply list
+ parent = thread["posts"].pop(0)
+ replies = thread["posts"]
+ thread["omitted"] = 0
+ #thread["omitted_img"] = 0
+
+ # Omit posts
+ while(len(replies) > replylimit):
+ post = replies.pop(0)
+ thread["omitted"] += 1
+ #if post["file"]:
+ # thread["omitted_img"] += 1
+
+ # Remake thread with necessary replies only
+ replies.insert(0, parent)
+ thread["posts"] = replies
+
+ # Shorten messages
+ for post in thread["posts"]:
+ post["shortened"], post["message"] = shortenMsg(post["message"])
+
+ # Build page according to page number
+ if page_num == 0:
+ file_name = "index"
+ else:
+ file_name = str(page_num)
+
+ if board['board_type'] == '1':
+ templatename = "txt_board.html"
+ else:
+ templatename = "board.html"
+
+ page_rendered = renderTemplate(templatename, {"threads": threads, "pagenav": pageNavigator(page_num, page_count, is_omitted), "more_threads": more_threads})
+
+ f = open(Settings.ROOT_DIR + board["dir"] + "/" + file_name + ".html", "w")
+ try:
+ f.write(page_rendered)
+ finally:
+ f.close()
+
+def threadList(mode=0):
+ board = Settings._.BOARD
+
+ if mode == 1:
+ mobile = True
+ maxthreads = 20
+ cutFactor = 100
+ elif mode == 2:
+ mobile = True
+ maxthreads = 1000
+ cutFactor = 50
+ elif mode == 3:
+ mobile = True
+ maxthreads = 1000
+ cutFactor = 100
+ else:
+ mobile = False
+ maxthreads = 1000
+ cutFactor = 70
+
+ if board['board_type'] == '1':
+ filename = "txt_threadlist.html"
+ full_threads = FetchAll("SELECT id, timestamp, timestamp_formatted, subject, length, last FROM `posts` WHERE parentid = 0 AND boardid = %(board)s AND IS_DELETED = 0 ORDER BY `bumped` DESC LIMIT %(limit)s" \
+ % {'board': board["id"], 'limit': maxthreads})
+ else:
+ filename = "threadlist.html"
+ full_threads = FetchAll("SELECT p.*, coalesce(x.count,1) AS length, coalesce(x.t,p.timestamp) AS last FROM `posts` AS p LEFT JOIN (SELECT parentid, count(1)+1 as count, max(timestamp) as t FROM `posts` " +\
+ "WHERE boardid = %(board)s GROUP BY parentid) AS x ON p.id=x.parentid WHERE p.parentid = 0 AND p.boardid = %(board)s AND p.IS_DELETED = 0 ORDER BY `bumped` DESC LIMIT %(limit)s" \
+ % {'board': board["id"], 'limit': maxthreads})
+
+ # Generate threadlist
+ timestamps = []
+ for thread in full_threads:
+ if board['board_type'] == '1':
+ thread["timestamp_formatted"] = thread["timestamp_formatted"].split(" ")[0]
+ timestamps.append([thread["last"], formatTimestamp(thread["last"])])
+ if mobile:
+ timestamps[-1][1] = re.compile(r"\(.{1,3}\)", re.DOTALL | re.IGNORECASE).sub(" ", timestamps[-1][1])
+ else:
+ if len(thread['message']) > cutFactor:
+ thread['shortened'] = True
+ else:
+ thread['shortened'] = False
+ thread['message'] = thread['message'].replace('<br />', ' ')
+ thread['message'] = thread['message'].split("<hr />")[0]
+ thread['message'] = re.compile(r"<[^>]*?>", re.DOTALL | re.IGNORECASE).sub('', thread['message'])
+ thread['message'] = thread['message'].decode('utf-8')[:cutFactor].encode('utf-8')
+ thread['message'] = re.compile(r"&(.(?!;))*$", re.DOTALL | re.IGNORECASE).sub('', thread['message']) # Removes incomplete HTML entities
+ thread['timestamp_formatted'] = re.compile(r"\(.{1,3}\)", re.DOTALL | re.IGNORECASE).sub(" ", thread['timestamp_formatted'])
+
+ # Get last reply if in mobile mode
+ if mode == 1:
+ thread['timestamp_formatted'] = re.compile(r"\(.{1,3}\)", re.DOTALL | re.IGNORECASE).sub(" ", thread['timestamp_formatted'])
+ lastreply = FetchOne("SELECT * FROM `posts` WHERE parentid = %s AND boardid = %s AND IS_DELETED = 0 ORDER BY `timestamp` DESC LIMIT 1" % (thread['id'], board['id']))
+ if lastreply:
+ if len(lastreply['message']) > 60:
+ lastreply['shortened'] = True
+ else:
+ lastreply['shortened'] = False
+ lastreply['message'] = lastreply['message'].replace('<br />', ' ')
+ lastreply['message'] = lastreply['message'].split("<hr />")[0]
+ lastreply['message'] = re.compile(r"<[^>]*?>", re.DOTALL | re.IGNORECASE).sub('', lastreply['message'])
+ lastreply['message'] = lastreply['message'].decode('utf-8')[:60].encode('utf-8')
+ lastreply['message'] = re.compile(r"&(.(?!;))*$", re.DOTALL | re.IGNORECASE).sub('', lastreply['message']) # Removes incomplete HTML entities
+ lastreply['timestamp_formatted'] = re.compile(r"\(.{1,3}\)", re.DOTALL | re.IGNORECASE).sub(" ", lastreply['timestamp_formatted'])
+ thread["lastreply"] = lastreply
+ else:
+ thread["lastreply"] = None
+ elif mode == 2:
+ lastreply = FetchOne("SELECT timestamp_formatted FROM `posts` WHERE parentid = %s AND boardid = %s AND IS_DELETED = 0 ORDER BY `timestamp` DESC LIMIT 1" % (thread['id'], board['id']))
+ if lastreply:
+ lastreply['timestamp_formatted'] = re.compile(r"\(.{1,3}\)", re.DOTALL | re.IGNORECASE).sub(" ", lastreply['timestamp_formatted'])
+ thread["lastreply"] = lastreply
+
+ return renderTemplate(filename, {"more_threads": full_threads, "timestamps": timestamps, "mode": mode}, mobile)
+
+def catalog(sort=''):
+ board = Settings._.BOARD
+
+ if board['board_type'] != '0':
+ raise UserError, "No hay catálogo disponible para esta sección."
+
+ cutFactor = 500
+
+ q_sort = '`bumped` DESC, `id` ASC'
+ if sort:
+ if sort == '1':
+ q_sort = '`timestamp` DESC'
+ elif sort == '2':
+ q_sort = '`timestamp` ASC'
+ elif sort == '3':
+ q_sort = '`length` DESC'
+ elif sort == '4':
+ q_sort = '`length` ASC'
+
+ threads = FetchAll("SELECT id, subject, message, length, thumb, expires_formatted FROM `posts` " +\
+ "WHERE parentid = 0 AND boardid = %(board)s AND IS_DELETED = 0 ORDER BY %(sort)s" \
+ % {'board': board["id"], 'sort': q_sort})
+
+ for thread in threads:
+ if len(thread['message']) > cutFactor:
+ thread['shortened'] = True
+ else:
+ thread['shortened'] = False
+ thread['message'] = thread['message'].replace('<br />', ' ')
+ thread['message'] = thread['message'].split("<hr />")[0]
+ thread['message'] = re.compile(r"<[^>]*?>", re.DOTALL | re.IGNORECASE).sub('', thread['message'])
+ thread['message'] = thread['message'].decode('utf-8')[:cutFactor].encode('utf-8')
+ thread['message'] = re.compile(r"&(.(?!;))*$", re.DOTALL | re.IGNORECASE).sub('', thread['message']) # Removes incomplete HTML entities
+
+ return renderTemplate("catalog.html", {"threads": threads, "i_sort": sort})
+
+def regenerateThreadPage(postid):
+ """
+ Regenerates /res/#.html for supplied thread id
+ """
+ board = Settings._.BOARD
+
+ thread = getThread(postid)
+
+ if board['board_type'] in ['1', '5']:
+ template_filename = "txt_thread.html"
+ outname = Settings.ROOT_DIR + board["dir"] + "/read/" + str(thread["timestamp"]) + ".html"
+ title_matome = thread['subject']
+ post_preview = cut_home_msg(thread['posts'][0]['message'], 0)
+ else:
+ template_filename = "board.html"
+ outname = Settings.ROOT_DIR + board["dir"] + "/res/" + str(postid) + ".html"
+ post_preview = cut_home_msg(thread['posts'][0]['message'], len(board['name']))
+
+ if thread['posts'][0]['subject'] != board['subject']:
+ title_matome = thread['posts'][0]['subject']
+ else:
+ title_matome = post_preview
+
+ page = renderTemplate(template_filename, {"threads": [thread], "replythread": postid, "matome": title_matome, "preview": post_preview}, False)
+
+ f = open(outname, "w")
+ try:
+ f.write(page)
+ finally:
+ f.close()
+
+def threadPage(postid, mobile=False, timestamp=0):
+ board = Settings._.BOARD
+
+ if board['board_type'] in ['1', '5']:
+ template_filename = "txt_thread.html"
+ else:
+ template_filename = "board.html"
+
+ threads = [getThread(postid, mobile, timestamp)]
+
+ return renderTemplate(template_filename, {"threads": threads, "replythread": postid}, mobile)
+
+def dynamicRead(parentid, ranges, mobile=False):
+ import re
+ board = Settings._.BOARD
+
+ if board['board_type'] != '1':
+ raise UserError, "Esta sección no es un BBS y como tal no soporta lectura dinámica."
+
+ # get entire thread
+ template_fname = "txt_thread.html"
+ thread = getThread(timestamp=parentid, mobile=mobile)
+
+ if not thread:
+ # Try the archive
+ fname = Settings.ROOT_DIR + board["dir"] + "/kako/" + str(parentid) + ".json"
+ if os.path.isfile(fname):
+ import json
+ with open(fname) as f:
+ thread = json.load(f)
+ thread['posts'] = [dict(zip(thread['keys'], row)) for row in thread['posts']]
+ template_fname = "txt_archive.html"
+ else:
+ raise UserError, 'El hilo no existe.'
+
+ filtered_thread = {
+ "id": thread['id'],
+ "timestamp": thread['timestamp'],
+ "length": thread['length'],
+ "subject": thread['subject'],
+ "locked": thread['locked'],
+ "posts": [],
+ }
+
+ if 'size' in thread:
+ filtered_thread['size'] = thread['size']
+
+ no_op = False
+ if ranges.endswith('n'):
+ no_op = True
+ ranges = ranges[:-1]
+
+ # get thread length
+ total = thread["length"]
+
+ # compile regex
+ __multiple_ex = re.compile("^([0-9]*)-([0-9]*)$")
+ __single_ex = re.compile("^([0-9]+)$")
+ __last_ex = re.compile("^l([0-9]+)$")
+ start = 0
+ end = 0
+
+ # separate by commas (,)
+ for range in ranges.split(','):
+ # single post (#)
+ range_match = __single_ex.match(range)
+ if range_match:
+ postid = int(range_match.group(1))
+ if postid > 0 and postid <= total:
+ filtered_thread["posts"].append(thread["posts"][postid-1])
+
+ # go to next range
+ continue
+
+ # post range (#-#)
+ range_match = __multiple_ex.match(range)
+ if range_match:
+ start = int(range_match.group(1) or 1)
+ end = int(range_match.group(2) or total)
+
+ if start > total:
+ start = total
+ if end > total:
+ end = total
+
+ if start < end:
+ filtered_thread["posts"].extend(thread["posts"][start-1:end])
+ else:
+ list = thread["posts"][end-1:start]
+ list.reverse()
+ filtered_thread["posts"].extend(list)
+
+ # go to next range
+ continue
+
+ # last posts (l#)
+ range_match = __last_ex.match(range)
+ if range_match:
+ length = int(range_match.group(1))
+ start = total - length + 1
+ end = total
+ if start < 1:
+ start = 1
+
+ filtered_thread["posts"].extend(thread["posts"][start-1:])
+
+ continue
+
+ # calculate previous and next ranges
+ prevrange = None
+ nextrange = None
+ if __multiple_ex.match(ranges) or __last_ex.match(ranges):
+ if mobile:
+ range_n = 50
+ else:
+ range_n = 100
+
+ prev_start = start-range_n
+ prev_end = start-1
+ next_start = end+1
+ next_end = end+range_n
+
+ if prev_start < 1:
+ prev_start = 1
+ if next_end > total:
+ next_end = total
+
+ if start > 1:
+ prevrange = '%d-%d' % (prev_start, prev_end)
+ if end < total:
+ nextrange = '%d-%d' % (next_start, next_end)
+
+ if not no_op and start > 1 and end > 1:
+ filtered_thread["posts"].insert(0, thread["posts"][0])
+
+ if not filtered_thread["posts"]:
+ raise UserError, "No hay posts que mostrar."
+
+ post_preview = cut_home_msg(filtered_thread["posts"][0]["message"], 0)
+
+ return renderTemplate(template_fname, {"threads": [filtered_thread], "replythread": parentid, "prevrange": prevrange, "nextrange": nextrange, "preview": post_preview}, mobile, noindex=True)
+
+def regenerateBoard(everything=False):
+ """
+ Update front pages and every thread res HTML page
+ """
+ board = Settings._.BOARD
+ op_posts = []
+
+ if everything:
+ op_posts = FetchAll("SELECT `id` FROM `posts` WHERE `boardid` = %s AND `parentid` = 0 AND IS_DELETED = 0" % board["id"])
+
+ # Use queues only if multithreading is enabled
+ if Settings.USE_MULTITHREADING:
+ request_queue = Queue.Queue()
+ threads = [RegenerateThread(i, request_queue) for i in range(Settings.MAX_PROGRAM_THREADS)]
+ for t in threads:
+ t.start()
+
+ request_queue.put("front")
+
+ for post in op_posts:
+ request_queue.put(post["id"])
+
+ for i in range(Settings.MAX_PROGRAM_THREADS):
+ request_queue.put(None)
+
+ for t in threads:
+ t.join()
+ else:
+ regenerateFrontPages()
+ for post in op_posts:
+ regenerateThreadPage(post["id"])
+
+def deletePost(postid, password, deltype='0', imageonly=False, quick=False):
+ """
+ Remove post from database and unlink file (if present), along with all replies
+ if supplied post is a thread
+ """
+ board = Settings._.BOARD
+
+ # make sure postid is numeric
+ postid = int(postid)
+
+ # get post
+ post = FetchOne("SELECT `id`, `timestamp`, `parentid`, `file`, `thumb`, `password`, `length` FROM `posts` WHERE `boardid` = %s AND `id` = %s LIMIT 1" % (board["id"], str(postid)))
+
+ # abort if the post doesn't exist
+ if not post:
+ raise UserError, _("There isn't a post with this ID. It was probably deleted.")
+
+ if password:
+ if password != post['password']:
+ raise UserError, "No tienes permiso para eliminar este mensaje."
+ if post["parentid"] == '0' and int(post["length"]) >= Settings.DELETE_FORBID_LENGTH:
+ raise UserError, "No puedes eliminar un hilo con tantas respuestas."
+ if (int(time.time()) - int(post["timestamp"])) > 86400:
+ raise UserError, "No puedes eliminar un post tan viejo."
+
+ # just update the DB if deleting only the image, otherwise delete whole post
+ if imageonly:
+ if post["file"]:
+ deleteFile(post)
+
+ UpdateDb("UPDATE `posts` SET `file` = '', `file_hex` = '', `thumb` = '', `thumb_width` = 0, `thumb_height` = 0 WHERE `boardid` = %s AND `id` = %s LIMIT 1" % (board["id"], str(post['id'])))
+ else:
+ if int(post["parentid"]) == 0:
+ deleteReplies(post)
+
+ logTime("Deleting post " + str(postid))
+ if deltype != '0' and post["parentid"] != '0':
+ # Soft delete (recycle bin)
+ UpdateDb("UPDATE `posts` SET `IS_DELETED` = %s WHERE `boardid` = %s AND `id` = %s LIMIT 1" % (deltype, board["id"], post["id"]))
+ else:
+ # Hard delete
+ if post["file"]:
+ deleteFile(post)
+
+ UpdateDb("DELETE FROM `posts` WHERE `boardid` = %s AND `id` = %s LIMIT 1" % (board["id"], post["id"]))
+ if post['parentid'] != '0':
+ UpdateDb("UPDATE `posts` SET length = %d WHERE `id` = '%s' AND `boardid` = '%s'" % (threadNumReplies(post["parentid"]), post["parentid"], board["id"]))
+
+ if post['parentid'] == '0':
+ if board['board_type'] == '1':
+ os.unlink(Settings.ROOT_DIR + board["dir"] + "/read/" + post["timestamp"] + ".html")
+ else:
+ os.unlink(Settings.ROOT_DIR + board["dir"] + "/res/" + post["id"] + ".html")
+
+ regenerateHome()
+
+ # rebuild thread and fronts if reply; rebuild only fronts if not
+ if post["parentid"] != '0':
+ threadUpdated(post["parentid"])
+ else:
+ regenerateFrontPages()
+
+def deleteReplies(thread):
+ board = Settings._.BOARD
+
+ # delete files first
+ replies = FetchAll("SELECT `parentid`, `file`, `thumb` FROM `posts` WHERE `boardid` = %s AND `parentid` = %s AND `file` != ''" % (board["id"], thread["id"]))
+ for post in replies:
+ deleteFile(post)
+
+ # delete all replies from DB
+ UpdateDb("DELETE FROM `posts` WHERE `boardid` = %s AND `parentid` = %s" % (board["id"], thread["id"]))
+
+def deleteFile(post):
+ """
+ Unlink file and thumb of supplied post
+ """
+ board = Settings._.BOARD
+
+ try:
+ os.unlink(Settings.IMAGES_DIR + board["dir"] + "/src/" + post["file"])
+ except:
+ pass
+
+ # we don't want to delete mime thumbnails
+ if post["thumb"].startswith("mime"):
+ return
+
+ try:
+ os.unlink(Settings.IMAGES_DIR + board["dir"] + "/thumb/" + post["thumb"])
+ except:
+ pass
+
+ try:
+ os.unlink(Settings.IMAGES_DIR + board["dir"] + "/mobile/" + post["thumb"])
+ except:
+ pass
+
+ if int(post["parentid"]) == 0:
+ try:
+ os.unlink(Settings.IMAGES_DIR + board["dir"] + "/cat/" + post["thumb"])
+ except:
+ pass
+
+def trimThreads():
+ """
+ Delete any threads which have passed the MAX_THREADS setting
+ """
+ logTime("Trimming threads")
+ board = Settings._.BOARD
+ archived = False
+
+ # Use limit of the board type
+ if board['board_type'] == '1':
+ limit = Settings.TXT_MAX_THREADS
+ else:
+ limit = Settings.MAX_THREADS
+
+ # trim expiring threads first
+ if board['maxage'] != '0':
+ t = time.time()
+
+ alert_time = int(round(int(board['maxage']) * Settings.MAX_AGE_ALERT))
+ time_limit = t + (alert_time * 86400)
+ old_ops = FetchAll("SELECT `id`, `timestamp`, `expires`, `expires_alert`, `length` FROM `posts` WHERE `boardid` = %s AND `parentid` = 0 AND IS_DELETED = 0 AND `expires` > 0 AND `expires` < %s LIMIT 50" % (board['id'], time_limit))
+
+ for op in old_ops:
+ if t >= int(op['expires']):
+ # Trim old threads
+ if board['archive'] == '1' and int(op["length"]) >= Settings.ARCHIVE_MIN_LENGTH:
+ archiveThread(op["id"])
+ archived = True
+
+ deletePost(op["id"], None)
+ else:
+ # Add alert to threads approaching deletion
+ UpdateDb("UPDATE `posts` SET expires_alert = 1 WHERE `boardid` = %s AND `id` = %s" % (board['id'], op['id']))
+
+ # trim inactive threads next
+ if board['maxinactive'] != '0':
+ t = time.time()
+
+ oldest_last = t - (int(board['maxinactive']) * 86400)
+ old_ops = FetchAll("SELECT `id`, `length` FROM `posts` WHERE `boardid` = %s AND `parentid` = 0 AND IS_DELETED = 0 AND `last` < %d LIMIT 50" % (board['id'], oldest_last))
+
+ for op in old_ops:
+ if board['archive'] == '1' and int(op["length"]) >= Settings.ARCHIVE_MIN_LENGTH:
+ archiveThread(op["id"])
+ archived = True
+
+ deletePost(op["id"], None)
+
+ # select trim type by board
+ if board['board_type'] == '1':
+ trim_method = Settings.TXT_TRIM_METHOD
+ else:
+ trim_method = Settings.TRIM_METHOD
+
+ # select order by trim
+ if trim_method == 1:
+ order = 'last DESC'
+ elif trim_method == 2:
+ order = 'bumped DESC'
+ else:
+ order = 'timestamp DESC'
+
+ # Trim the last thread
+ op_posts = FetchAll("SELECT `id`, `length` FROM `posts` WHERE `boardid` = %s AND `parentid` = 0 AND IS_DELETED = 0 ORDER BY %s" % (board["id"], order))
+ if len(op_posts) > limit:
+ posts = op_posts[limit:]
+ for post in posts:
+ if board['archive'] == '1' and int(op["length"]) >= Settings.ARCHIVE_MIN_LENGTH:
+ archiveThread(post["id"])
+ archived = True
+
+ deletePost(post["id"], None)
+ pass
+
+ if archived:
+ regenerateKako()
+
+def autoclose_thread(parentid, t, replies):
+ """
+ If the thread is crossing the reply limit, close it with a message.
+ """
+ board = Settings._.BOARD
+
+ # decide the replylimit
+ if board['board_type'] == '1' and Settings.TXT_CLOSE_THREAD_ON_REPLIES > 0:
+ replylimit = Settings.TXT_CLOSE_THREAD_ON_REPLIES
+ elif Settings.CLOSE_THREAD_ON_REPLIES > 0:
+ replylimit = Settings.CLOSE_THREAD_ON_REPLIES
+ else:
+ return # do nothing
+
+ # close it if passing replylimit
+ #if replies >= replylimit or board["dir"] == "polka":
+ if replies >= replylimit:
+ notice_post = Post(board["id"])
+ notice_post["parentid"] = parentid
+ notice_post["name"] = "Sistema"
+ notice_post["message"] = "El hilo ha llegado al límite de respuestas.<br />Si quieres continuarlo, por favor crea otro."
+ notice_post["timestamp"] = t+1
+ notice_post["bumped"] = get_parent_post(parentid, board["id"])["bumped"]
+ notice_post["timestamp_formatted"] = str(replylimit) + " mensajes"
+ notice_post.insert()
+ UpdateDb("UPDATE `posts` SET `locked` = 1 WHERE `boardid` = '%s' AND `id` = '%s' LIMIT 1" % (board["id"], _mysql.escape_string(parentid)))
+
+def pageNavigator(page_num, page_count, is_omitted=False):
+ """
+ Create page navigator in the format of [0], [1], [2]...
+ """
+ board = Settings._.BOARD
+
+ # No threads?
+ if page_count == 0:
+ return ''
+
+ # TODO nijigen HACK
+ first_str = "Primera página"
+ last_str = "Última página"
+ previous_str = _("Previous")
+ next_str = _("Next")
+ omitted_str = "Resto omitido"
+
+ pagenav = "<span>"
+ if page_num == 0:
+ pagenav += first_str
+ else:
+ previous = str(page_num - 1)
+ if previous == "0":
+ previous = ""
+ else:
+ previous = previous + ".html"
+ pagenav += '<form method="get" action="' + Settings.BOARDS_URL + board["dir"] + '/' + previous + '"><input value="'+previous_str+'" type="submit" /></form>'
+
+ pagenav += "</span><span>"
+
+ for i in xrange(page_count):
+ if i == page_num:
+ pagenav += "[<strong>%d</strong>]" % i
+ else:
+ if i == 0:
+ pagenav += '[<a href="%s%s/">%d</a>]' % (Settings.BOARDS_URL, board['dir'], i)
+ else:
+ pagenav += '[<a href="%s%s/%d.html">%d</a>]' % (Settings.BOARDS_URL, board['dir'], i, i)
+
+ if i > 0 and (i % 10) == 0 and not is_omitted:
+ pagenav += '<br />'
+ elif i < 10:
+ pagenav += '&nbsp;'
+
+ if is_omitted:
+ pagenav += "[" + omitted_str + "]"
+
+ pagenav += "<!-- "+repr(is_omitted)+"-->"
+ pagenav += "</span><span>"
+
+ next = (page_num + 1)
+ if next == page_count:
+ pagenav += last_str + "</span>"
+ else:
+ pagenav += '<form method="get" action="' + Settings.BOARDS_URL + board["dir"] + '/' + str(next) + '.html"><input value="'+next_str+'" type="submit" /></form></span>'
+
+ return pagenav
+
+def flood_check(t,post,boardid):
+ board = Settings._.BOARD
+
+ if not post["parentid"]:
+ maxtime = t - int(board['threadsecs'])
+ #lastpost = FetchOne("SELECT COUNT(*) FROM `posts` WHERE `ip` = '%s' and `parentid` = 0 and `boardid` = '%s' and IS_DELETED = 0 AND timestamp > %d" % (str(post["ip"]), boardid, maxtime), 0)
+
+ # NO MATTER THE IP
+ lastpost = FetchOne("SELECT COUNT(*) FROM `posts` WHERE `parentid` = 0 and `boardid` = '%s' and IS_DELETED = 0 AND timestamp > %d" % (boardid, maxtime), 0)
+ pass
+ else:
+ maxtime = t - int(board['postsecs'])
+ lastpost = FetchOne("SELECT COUNT(*) FROM `posts` WHERE `ip` = '%s' and `parentid` != 0 and `boardid` = '%s' and IS_DELETED = 0 AND timestamp > %d" % (str(post["ip"]), boardid, maxtime), 0)
+
+ if int(lastpost[0]):
+ if post["parentid"]:
+ raise UserError, _("Flood detected. Please wait a moment before posting again.")
+ else:
+ lastpost = FetchOne("SELECT `timestamp` FROM `posts` WHERE `parentid`=0 and `boardid`='%s' and IS_DELETED = 0 ORDER BY `timestamp` DESC" % (boardid), 0)
+ wait = int(int(board['threadsecs']) - (t - int(lastpost[0])))
+ raise UserError, "Por favor espera " + str(wait) + " segundos antes de crear otro hilo."
+
+def cut_home_msg(message, boardlength=0):
+ short_message = message.replace("<br />", " ")
+ short_message = short_message.split("<hr />")[0]
+ short_message = re.compile(r"<[^>]*?>", re.DOTALL | re.IGNORECASE).sub("", short_message) # Removes HTML tags
+ limit = Settings.HOME_LASTPOSTS_LENGTH - boardlength
+
+ if len(short_message) > limit:
+ if isinstance(short_message, unicode):
+ short_message = short_message[:limit].encode('utf-8') + "…"
+ else:
+ short_message = short_message.decode('utf-8')[:limit].encode('utf-8') + "…"
+ short_message = re.compile(r"&(.(?!;))*$", re.DOTALL | re.IGNORECASE).sub("", short_message) # Removes incomplete HTML
+ return short_message
+
+def getLastAge(limit):
+ threads = []
+ sql = "SELECT posts.id, boards.name AS board_fulln, boards.subname AS board_name, board_type, boards.dir, timestamp, bumped, last, length, thumb, CASE WHEN posts.subject = boards.subject THEN posts.message ELSE posts.subject END AS content FROM posts INNER JOIN boards ON boardid = boards.id WHERE parentid = 0 AND IS_DELETED = 0 AND boards.secret = 0 AND posts.locked < 3 ORDER BY bumped DESC LIMIT %d" % limit
+ threads = FetchAll(sql)
+
+ for post in threads:
+ post['id'] = int(post['id'])
+ post['bumped'] = int(post['bumped'])
+ post['last'] = int(post['last'])
+ post['length'] = int(post['length'])
+ post['board_type'] = int(post['board_type'])
+ post['timestamp'] = int(post['timestamp'])
+ post['content'] = cut_home_msg(post['content'], 0)
+
+ if post['board_type'] == 1:
+ post['url'] = '/%s/read/%d/l10' % (post['dir'], post['timestamp'])
+ else:
+ post['url'] = '/%s/res/%d.html' % (post['dir'], post['id'])
+
+ return threads
+
+def getNewThreads(limit):
+ threads = []
+ sql = "SELECT posts.id, boards.name AS board_fulln, boards.subname AS board_name, board_type, boards.dir, timestamp, thumb, CASE WHEN posts.subject = boards.subject THEN posts.message ELSE posts.subject END AS content FROM posts INNER JOIN boards ON boardid = boards.id WHERE parentid = 0 AND IS_DELETED = 0 AND boards.secret = 0 AND boards.id <> 34 AND boards.id <> 13 AND posts.locked = 0 ORDER BY timestamp DESC LIMIT %d" % (limit)
+ threads = FetchAll(sql)
+
+ for post in threads:
+ post['id'] = int(post['id'])
+ post['board_type'] = int(post['board_type'])
+ post['timestamp'] = int(post['timestamp'])
+ post['timestamp_formatted'] = formatTimestamp(post['timestamp'], True)
+ post['timestamp_formatted'] = post['timestamp_formatted'][:8] + ' ' + post['timestamp_formatted'][13:]
+ post['content'] = cut_home_msg(post['content'], 0)
+ if post['board_type'] == 1:
+ post['url'] = '/%s/read/%d' % (post['dir'], post['timestamp'])
+ else:
+ post['url'] = '/%s/res/%d.html' % (post['dir'], post['id'])
+
+ return threads
+
+def regenerateHome():
+ """
+ Update index.html in the boards directory with useful data for users
+ """
+ logTime("Updating home")
+ t = datetime.datetime.now()
+
+ limit = Settings.HOME_LASTPOSTS
+ template_values = {
+ 'header': Settings.SITE_TITLE,
+ 'slogan': Settings.SITE_SLOGAN,
+ 'latest_news': FetchAll("SELECT `timestamp`, `message`, `timestamp_formatted` FROM `news` WHERE `type` = '2' ORDER BY `timestamp` DESC LIMIT " + str(Settings.HOME_NEWS)),
+ 'latest_age': getLastAge(limit),
+ 'latest_age_num': limit,
+ 'new_threads': getNewThreads(Settings.HOME_NEWTHREADS),
+ }
+
+ page_rendered = renderTemplate('home.html', template_values)
+ f = open(Settings.HOME_DIR + "home.html", "w")
+ try:
+ f.write(page_rendered)
+ finally:
+ f.close()
+
+ if Settings.ENABLE_RSS:
+ sql = "SELECT id, boardid, board_name, timestamp, timestamp_formatted, content, url FROM last ORDER BY timestamp DESC LIMIT 10"
+ rss = FetchAll(sql)
+ rss_rendered = renderTemplate('home.rss', {'posts': rss})
+ f = open(Settings.HOME_DIR + "bai.rss", "w")
+ try:
+ f.write(rss_rendered)
+ finally:
+ f.close()
+
+def regenerateNews():
+ """
+ Update news.html in the boards directory with older news
+ """
+ posts = FetchAll("SELECT * FROM `news` WHERE `type` = '1' ORDER BY `timestamp` DESC")
+ template_values = {
+ 'title': 'Noticias',
+ 'posts': posts,
+ 'header': Settings.SITE_TITLE,
+ 'slogan': Settings.SITE_SLOGAN,
+ 'navbar': False,
+ }
+
+ page_rendered = renderTemplate('news.html', template_values)
+
+ f = open(Settings.HOME_DIR + "noticias.html", "w")
+ try:
+ f.write(page_rendered)
+ finally:
+ f.close()
+
+def regenerateAccess():
+ if not Settings.HTACCESS_GEN:
+ return False
+
+ bans = FetchAll("SELECT INET_NTOA(`ip`) AS 'ip', INET_NTOA(`netmask`) AS 'netmask', `boards` FROM `bans` WHERE `blind` = '1'")
+ listbans = dict()
+ #listbans_global = list()
+
+ boarddirs = FetchAll('SELECT `dir` FROM `boards`')
+ for board in boarddirs:
+ listbans[board['dir']] = list()
+
+ for ban in bans:
+ ipmask = ban["ip"]
+ if ban["netmask"] is not None:
+ ipmask += '/' + ban["netmask"]
+
+ if ban["boards"] != "":
+ boards = pickle.loads(ban["boards"])
+ for board in boards:
+ listbans[board].append(ipmask)
+ else:
+ #listbans_global.append(ban["ip"])
+ for board in boarddirs:
+ if board['dir'] not in Settings.EXCLUDE_GLOBAL_BANS:
+ listbans[board['dir']].append(ipmask)
+
+ # Generate .htaccess for each board
+ for board in listbans.keys():
+ template_values = {
+ 'ips': listbans[board],
+ 'dir': board,
+ }
+
+ page_rendered = renderTemplate('htaccess', template_values)
+ f = open(Settings.ROOT_DIR + board + "/.htaccess", "w")
+ try:
+ f.write(page_rendered)
+ finally:
+ f.close()
+
+ return True
+
+def regenerateKako():
+ board = Settings._.BOARD
+
+ threads = FetchAll("SELECT * FROM archive WHERE boardid = %s ORDER BY timestamp DESC" % board['id'])
+ page = renderTemplate('kako.html', {'threads': threads})
+ with open(Settings.ROOT_DIR + board["dir"] + "/kako/index.html", "w") as f:
+ f.write(page)
+
+def make_url(postid, post, parent_post, noko, mobile):
+ board = Settings._.BOARD
+
+ parentid = post["parentid"]
+ if not parentid:
+ parentid = postid
+
+ if mobile:
+ if not noko:
+ url = Settings.CGI_URL + 'mobile/' + board["dir"]
+ elif board["board_type"] == '1':
+ url = "%s/mobileread/%s/%s/l10#form" % (Settings.CGI_URL, board["dir"], parent_post['timestamp'])
+ else:
+ url = "%s/mobileread/%s/%s#%s" % (Settings.CGI_URL, board["dir"], parentid, postid)
+ else:
+ if not noko:
+ url = Settings.BOARDS_URL + board["dir"] + "/"
+ elif board["board_type"] == '1':
+ url = "%s/read/%s/l50#bottom" % (Settings.BOARDS_URL + board["dir"], str(parent_post['timestamp']))
+ else:
+ url = "%s/res/%s.html#%s" % (Settings.BOARDS_URL + board["dir"], str(parentid), postid)
+
+ return url
+
+def make_redirect(url, timetaken=None):
+ board = Settings._.BOARD
+ randomPhrase = getRandomLine('quotes.conf')
+
+ return renderTemplate('redirect.html', {'url': url, 'message': randomPhrase, 'timetaken': timetaken})
+
+def latestAdd(post, postnum, postid, parent_post):
+ board = Settings._.BOARD
+
+ #UpdateDb("DELETE FROM last LIMIT 15, 500#")
+
+ if post['subject'] and post['subject'] != board["subject"]:
+ content = post['subject']
+ else:
+ content = cut_home_msg(post['message'], len(board['name']))
+
+ timestamp_formatted = datetime.datetime.fromtimestamp(post['timestamp']).strftime('%Y-%m-%dT%H:%M:%S%Z')
+ parentid = parent_post['id'] if post['parentid'] else postid
+
+ if board['board_type'] == '1':
+ url = '/%s/read/%s/%d' % (board['dir'], (parent_post['timestamp'] if post['parentid'] else post['timestamp']), (postnum if postnum else 1))
+ else:
+ url = '/%s/res/%s.html#%s' % (board['dir'], parentid, postid)
+
+ sql = "INSERT INTO last (id, boardid, board_name, timestamp, timestamp_formatted, content, url) VALUES ('%s', '%s', '%s', '%s', '%s', '%s', '%s')" % (str(postid), board['id'], _mysql.escape_string(board['name']), post['timestamp'], _mysql.escape_string(timestamp_formatted), _mysql.escape_string(content), _mysql.escape_string(url))
+ UpdateDb(sql)
+
+def latestRemove(postid):
+ board = Settings._.BOARD
+ UpdateDb("DELETE FROM last WHERE id = %s AND boardid = %s" % (str(postid), board['id']))
+
+def archiveThread(postid):
+ import json
+ board = Settings._.BOARD
+
+ thread = getThread(postid, False)
+ post_preview = cut_home_msg(thread['posts'][0]['message'], 0)
+
+ page = renderTemplate("txt_archive.html", {"threads": [thread], "replythread": postid, "preview": post_preview}, False)
+ with open(Settings.ROOT_DIR + board["dir"] + "/kako/" + str(thread['timestamp']) + ".html", "w") as f:
+ f.write(page)
+
+ thread['keys'] = ['num', 'IS_DELETED', 'name', 'tripcode', 'email', 'message', 'timestamp_formatted']
+ thread['posts'] = [[row[key] for key in thread['keys']] for row in thread['posts']]
+ try:
+ with open(Settings.ROOT_DIR + board["dir"] + "/kako/" + str(thread['timestamp']) + ".json", "w") as f:
+ json.dump(thread, f, indent=0)
+ except:
+ raise UserError, "Can't archive: %s" % thread['timestamp']
+
+ UpdateDb("REPLACE INTO archive (id, boardid, timestamp, subject, length) VALUES ('%s', '%s', '%s', '%s', '%s')" % (thread['id'], board['id'], thread['timestamp'], _mysql.escape_string(thread['subject']), thread['length']))
+
+def throw_dice(dice):
+ qty = int(dice[0][1:])
+ if qty == 0:
+ raise UserError, "No tienes dados para lanzar."
+ if qty > 100:
+ qty = 100
+ sides = int(dice[1][1:]) if dice[1] else 6
+ if sides == 0:
+ raise UserError, "Tus dados no tienen caras."
+ if sides > 100:
+ sides = 100
+
+ string = "Lanzas "
+ string += "un dado de " if qty == 1 else (str(qty) + " dados de ")
+ string += "una cara." if sides == 1 else (str(sides) + " caras.")
+ string += " Resultado: <b>"
+
+ total = i = 0
+ while (i < qty):
+ total += random.randint(1,sides)
+ i += 1
+
+ string += str(total) + "</b>"
+
+ return string
+
+def magic_ball():
+ string = "La bola 8 mágica dice: <b>"
+ results = ["Sí.", "Es seguro.", "En mi opinión, sí.", "Está decidido que sí.", "Definitivamente sí.", "Es lo más probable.", "Buen pronóstico.", "Puedes confiar en ello.", "Todo apunta a que sí.", "Sin duda.", "Respuesta vaga, vuelve a intentarlo.", "Pregunta en otro momento.", "Mejor que no te lo diga ahora.", "No puedo predecirlo ahora.", "Concéntrate y vuelve a preguntar.", "No cuentes con ello.", "Mi respuesta es no.", "Mis fuentes me dicen que no.", "Pronóstico no muy bueno.", "Muy dudoso."]
+ string += random.choice(results) + "</b>"
+
+ return string
+
+def discord_hook(post, url):
+ import urllib2
+ import json
+
+ board = Settings._.BOARD
+
+ WEBHOOK_URL = "https://discordapp.com/api/webhooks/428025764974166018/msYu1-R3JRnG-cxrhAu3J7LbIPvzpBlJwbW5PFe5VEQaxVzjros9CXOpjZDahUE42Jgn"
+
+ data = {"content": "",
+ "ts": post['timestamp'],
+ "embeds": [{
+ "title": post['subject'],
+ "description": cut_home_msg(post['message'], 30),
+ "url": "https://bienvenidoainternet.org" + url,
+ "color": 11910504,
+ "timestamp": datetime.datetime.utcfromtimestamp(post['timestamp']).isoformat(),
+ "footer": { "text": board['name'] },
+ "thumbnail": { "url": "https://bienvenidoainternet.org/%s/thumb/%s" % (board['dir'], post['thumb']) },
+ "author": {
+ "name": "Nuevo hilo",
+ "icon_url": "https://bienvenidoainternet.org/0/junk/w/shobon.gif"
+ }}]
+ }
+ jsondata = json.dumps(data, separators=(',',':'))
+
+ opener = urllib2.build_opener()
+ opener.addheaders = [('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:59.0) Gecko/20100101 Firefox/59.0')]
+ response = opener.open(WEBHOOK_URL, jsondata, 6)
+ the_page = response.read()
diff --git a/cgi/proxy.txt b/cgi/proxy.txt
new file mode 100644
index 0000000..5fc9460
--- /dev/null
+++ b/cgi/proxy.txt
@@ -0,0 +1,3251 @@
+190.151.94.45
+186.67.46.229
+109.107.112.139
+109.107.112.250
+109.107.97.45
+114.127.246.36
+114.224.167.170
+115.99.10.9
+116.17.92.221
+116.26.61.105
+116.48.224.179
+116.5.232.33
+116.72.165.208
+116.72.189.248
+117.102.81.138
+118.175.22.21
+118.220.175.207
+118.98.161.122
+118.98.165.42
+118.98.168.18
+119.115.52.109
+119.235.25.242
+119.62.128.38
+119.63.86.78
+119.70.40.101
+12.13.94.227
+12.208.118.96
+12.208.14.76
+12.208.168.97
+12.208.190.71
+12.240.37.195
+12.4.27.18
+12.47.164.114
+12.51.72.38
+12.72.169.156
+120.118.254.248
+120.125.80.133
+120.138.117.249
+120.138.80.226
+120.28.64.69
+121.108.31.23
+121.207.206.137
+121.207.42.244
+121.242.41.18
+121.245.199.202
+121.246.207.88
+121.52.147.45
+121.52.49.218
+121.58.193.10
+121.8.98.90
+121.9.230.162
+122.103.167.189
+122.103.185.182
+122.107.124.56
+122.116.65.95
+122.166.15.47
+122.17.74.118
+122.224.97.85
+122.6.245.14
+123.127.110.247
+123.131.44.66
+123.175.204.208
+123.233.121.164
+123.6.19.97
+124.115.177.53
+124.124.19.3
+124.129.174.114
+124.138.92.206
+124.195.158.155
+124.207.168.48
+124.217.198.4
+124.237.86.10
+124.30.233.111
+124.40.121.3
+124.53.159.169
+124.53.159.185
+124.81.224.174
+124.81.231.1
+125.135.15.181
+125.14.35.130
+125.160.244.159
+125.163.255.154
+125.240.55.130
+125.243.140.2
+125.243.149.130
+125.245.187.2
+125.245.196.194
+125.245.211.2
+125.248.165.178
+125.34.135.4
+125.34.30.201
+125.40.59.193
+125.46.24.134
+125.46.34.53
+125.46.95.230
+125.65.113.61
+129.22.90.248
+129.70.142.34
+129.93.193.140
+130.63.177.192
+131.196.115.10
+131.196.13.5
+134.39.27.37
+138.0.152.162
+138.0.231.66
+138.117.109.154
+138.117.143.226
+138.117.149.68
+138.117.84.90
+138.122.191.224
+138.186.176.175
+138.186.178.161
+138.186.179.219
+138.186.201.214
+138.36.98.107
+138.59.176.244
+138.59.176.246
+138.59.176.67
+140.113.169.49
+140.116.23.198
+140.117.178.234
+140.134.104.195
+140.134.208.93
+141.13.16.201
+141.13.16.202
+141.85.118.1
+141.85.254.118
+142.150.3.77
+142.59.52.201
+142.59.90.148
+143.255.142.99
+143.50.28.215
+146.101.147.140
+147.75.123.141
+147.75.123.142
+148.233.239.23
+148.233.239.24
+148.243.37.101
+148.244.102.98
+149.202.5.57
+151.11.232.92
+151.204.42.140
+151.79.46.58
+152.169.230.136
+152.204.130.62
+152.231.29.196
+152.231.74.252
+152.231.81.122
+156.17.93.77
+156.34.176.45
+157.182.52.224
+16.225.151.192
+160.7.251.98
+161.10.228.42
+161.132.100.69
+161.132.111.83
+161.132.176.28
+162.129.45.61
+163.17.151.126
+163.17.189.254
+163.24.133.117
+163.29.225.250
+163.30.113.254
+163.30.32.90
+164.77.134.13
+164.77.170.68
+164.78.252.25
+165.228.128.11
+165.228.132.11
+165.229.203.100
+168.196.112.250
+168.196.114.254
+168.234.75.142
+168.90.12.125
+168.90.12.126
+170.239.84.248
+170.239.86.37
+170.254.230.226
+170.78.46.53
+170.79.16.19
+170.84.126.164
+172.162.51.225
+172.163.102.206
+172.163.122.212
+172.163.195.118
+172.163.2.27
+172.164.11.216
+172.165.127.246
+172.166.67.103
+172.190.25.229
+172.192.219.161
+173.45.229.206
+174.0.50.242
+174.129.225.131
+174.142.104.57
+174.142.24.201
+174.142.36.90
+174.49.93.138
+176.56.122.156
+176.56.122.194
+176.56.98.135
+177.230.105.9
+177.234.12.202
+177.234.7.66
+177.250.223.82
+178.60.28.98
+179.42.186.120
+179.43.98.74
+179.49.114.110
+179.60.240.233
+179.60.241.246
+179.63.253.42
+181.10.176.50
+181.111.56.248
+181.112.138.198
+181.112.147.218
+181.112.147.90
+181.112.152.222
+181.112.188.90
+181.112.218.146
+181.112.219.187
+181.112.221.182
+181.112.221.2
+181.112.227.142
+181.112.228.126
+181.112.37.38
+181.112.39.186
+181.112.47.246
+181.112.51.174
+181.112.53.114
+181.112.55.6
+181.112.60.62
+181.112.61.146
+181.112.61.162
+181.112.61.178
+181.112.62.26
+181.113.116.134
+181.113.121.158
+181.113.123.58
+181.113.20.186
+181.113.28.150
+181.113.30.222
+181.113.5.142
+181.113.5.146
+181.113.5.186
+181.114.21.154
+181.115.241.90
+181.119.115.17
+181.129.1.154
+181.129.131.74
+181.129.2.186
+181.129.30.190
+181.129.39.42
+181.129.40.42
+181.129.51.106
+181.143.103.170
+181.143.123.114
+181.143.162.189
+181.143.186.82
+181.143.210.61
+181.143.221.226
+181.143.47.28
+181.143.65.117
+181.143.73.34
+181.143.9.34
+181.15.156.190
+181.167.159.174
+181.168.140.136
+181.168.148.151
+181.170.196.130
+181.174.79.53
+181.177.141.233
+181.177.242.82
+181.177.242.84
+181.188.130.14
+181.188.156.51
+181.188.199.152
+181.189.210.1
+181.189.235.11
+181.192.13.15
+181.196.145.106
+181.196.150.166
+181.196.178.106
+181.196.207.66
+181.196.27.162
+181.196.27.198
+181.196.28.34
+181.196.50.238
+181.196.57.54
+181.196.77.194
+181.199.178.12
+181.199.202.248
+181.211.101.10
+181.211.114.110
+181.211.13.126
+181.211.187.250
+181.211.191.228
+181.211.2.82
+181.211.3.190
+181.211.57.10
+181.211.59.22
+181.225.78.142
+181.225.79.164
+181.229.121.134
+181.29.113.251
+181.29.126.81
+181.39.17.138
+181.39.223.96
+181.39.28.228
+181.39.96.154
+181.40.115.186
+181.40.46.204
+181.40.93.118
+181.41.246.100
+181.44.24.56
+181.45.61.153
+181.47.9.239
+181.48.167.142
+181.48.216.38
+181.48.47.26
+181.49.121.21
+181.49.160.90
+181.49.200.90
+181.49.213.166
+181.49.41.206
+181.49.44.14
+181.49.50.226
+181.52.172.253
+181.55.148.39
+181.55.151.170
+181.55.188.184
+181.56.9.161
+181.57.156.186
+181.64.106.201
+181.64.233.120
+181.65.133.210
+181.66.51.109
+181.67.238.120
+181.74.133.166
+185.155.67.160
+185.177.74.179
+185.19.214.48
+185.65.186.192
+185.74.192.176
+185.98.232.119
+185.98.232.182
+186.0.195.194
+186.0.95.78
+186.1.180.214
+186.1.180.217
+186.1.183.102
+186.10.5.141
+186.10.5.142
+186.10.79.138
+186.101.136.10
+186.101.33.146
+186.103.154.101
+186.103.169.164
+186.103.169.166
+186.103.239.190
+186.117.128.92
+186.121.252.131
+186.147.161.185
+186.156.228.41
+186.156.236.98
+186.156.85.228
+186.178.10.78
+186.178.7.142
+186.18.225.108
+186.3.1.187
+186.38.79.28
+186.4.186.119
+186.4.192.202
+186.4.200.145
+186.42.161.30
+186.42.185.114
+186.42.188.110
+186.42.198.218
+186.42.97.154
+186.46.120.138
+186.46.128.102
+186.46.136.42
+186.46.138.14
+186.46.138.186
+186.46.151.22
+186.46.152.142
+186.46.153.174
+186.46.156.202
+186.46.163.102
+186.46.192.242
+186.46.232.34
+186.46.232.38
+186.46.250.14
+186.46.27.70
+186.46.28.14
+186.46.85.154
+186.46.88.226
+186.46.94.18
+186.46.94.54
+186.47.100.30
+186.47.102.58
+186.47.212.166
+186.47.23.118
+186.47.23.130
+186.47.232.250
+186.47.46.6
+186.47.72.198
+186.47.72.58
+186.47.96.90
+186.65.81.140
+186.67.90.12
+186.68.85.26
+186.68.93.46
+186.74.135.245
+186.74.190.82
+186.83.66.119
+186.88.21.87
+186.89.125.167
+186.89.227.238
+186.89.98.56
+186.91.185.237
+186.91.186.112
+186.91.222.58
+186.92.138.184
+186.92.144.24
+186.92.158.52
+186.92.167.175
+186.92.25.250
+186.92.85.128
+186.93.86.242
+186.95.18.58
+186.95.181.177
+186.95.220.5
+186.95.243.219
+186.95.57.249
+186.95.59.223
+187.11.250.36
+187.134.221.231
+187.141.79.55
+187.160.116.145
+187.160.149.88
+187.160.154.147
+187.160.245.156
+187.161.209.78
+187.161.228.47
+187.161.3.226
+187.188.182.116
+187.189.115.24
+187.189.26.10
+187.189.26.61
+187.189.47.196
+187.189.73.164
+187.189.75.110
+187.190.221.61
+187.190.64.28
+187.217.189.229
+187.237.138.186
+187.243.251.110
+187.243.251.30
+187.243.251.42
+187.246.183.227
+187.250.102.60
+187.49.217.2
+189.109.46.210
+189.111.166.103
+189.111.166.127
+189.122.171.234
+189.122.197.251
+189.126.103.252
+189.127.143.70
+189.14.68.130
+189.166.116.138
+189.19.10.23
+189.19.168.149
+189.19.60.123
+189.195.162.222
+189.196.15.9
+189.198.199.122
+189.198.239.74
+189.199.112.138
+189.20.207.150
+189.204.116.221
+189.204.219.149
+189.209.110.202
+189.211.209.44
+189.218.126.199
+189.218.127.200
+189.218.214.244
+189.218.68.175
+189.219.103.120
+189.219.229.50
+189.219.24.155
+189.219.241.16
+189.219.241.174
+189.219.76.118
+189.219.80.167
+189.219.88.243
+189.219.92.138
+189.219.99.41
+189.221.102.190
+189.23.208.37
+189.23.6.3
+189.26.245.173
+189.29.117.58
+189.29.27.185
+189.3.176.130
+189.37.28.147
+189.39.115.185
+189.47.129.62
+189.47.137.189
+189.53.181.9
+189.55.174.28
+189.55.219.176
+189.55.219.252
+189.56.61.33
+189.60.224.13
+189.79.63.28
+189.8.41.58
+190.0.22.10
+190.0.25.242
+190.0.35.6
+190.1.174.162
+190.101.137.157
+190.102.206.48
+190.103.31.61
+190.104.156.126
+190.104.179.138
+190.104.179.254
+190.104.195.210
+190.104.233.24
+190.104.69.9)
+190.104.71.30
+190.107.16.37
+190.108.192.24
+190.109.169.41
+190.11.115.133
+190.11.121.126
+190.11.26.58
+190.110.192.124
+190.111.233.208
+190.111.243.170
+190.112.108.29
+190.112.126.143
+190.112.40.138
+190.112.40.54
+190.112.41.116
+190.112.42.186
+190.116.175.2
+190.117.101.146
+190.117.115.150
+190.117.167.168
+190.117.167.239
+190.117.181.60
+190.12.102.205
+190.12.18.134
+190.12.48.158
+190.12.58.187
+190.12.63.166
+190.12.65.66
+190.121.158.114
+190.121.158.122
+190.121.167.218
+190.121.29.235
+190.122.20.85
+190.128.30.14
+190.129.1.141
+190.129.25.59
+190.13.174.19
+190.130.207.201
+190.131.203.90
+190.131.223.210
+190.131.235.134
+190.131.254.91
+190.138.249.182
+190.139.101.154
+190.139.49.20
+190.14.213.26
+190.14.233.90
+190.14.237.66
+190.14.245.142
+190.141.37.122
+190.142.221.66
+190.142.221.81
+190.145.30.188
+190.145.45.150
+190.145.80.114
+190.147.1.8
+190.147.100.219
+190.147.134.57
+190.147.160.240
+190.15.207.242
+190.15.213.137
+190.15.221.21
+190.15.222.24
+190.151.10.226
+190.151.68.116
+190.152.149.114
+190.152.150.62
+190.152.16.43
+190.152.18.182
+190.152.19.190
+190.152.37.114
+190.152.37.58
+190.152.4.54
+190.153.137.15
+190.153.142.191
+190.153.210.237
+190.163.22.64
+190.171.215.160
+190.18.134.27
+190.18.207.50
+190.180.62.246
+190.184.31.194
+190.186.111.59
+190.186.242.12
+190.186.46.14
+190.186.46.7
+190.186.5.54
+190.186.55.194
+190.186.58.170
+190.186.65.201
+190.186.7.114
+190.186.7.38
+190.196.168.46
+190.196.4.51
+190.198.30.0
+190.198.89.58
+190.199.213.29
+190.199.245.82
+190.200.63.90
+190.202.15.83
+190.203.229.8
+190.203.51.177
+190.204.0.202
+190.205.220.125
+190.205.45.146
+190.206.1.33
+190.206.143.42
+190.206.2.24
+190.207.254.204
+190.210.37.42
+190.210.99.221
+190.210.99.241
+190.211.80.154
+190.214.1.26
+190.214.10.54
+190.214.14.38
+190.214.31.230
+190.214.44.74
+190.214.56.138
+190.214.56.142
+190.215.253.251
+190.216.198.123
+190.217.3.162
+190.217.4.102
+190.217.55.10
+190.217.6.121
+190.219.217.140
+190.221.138.29
+190.225.18.133
+190.226.227.140
+190.228.202.235
+190.228.33.114
+190.228.38.78
+190.228.47.10
+190.23.175.185
+190.232.115.179
+190.232.168.242
+190.234.168.144
+190.234.181.250
+190.236.177.213
+190.236.188.8
+190.238.11.199
+190.238.68.190
+190.239.144.37
+190.239.148.244
+190.24.145.124
+190.242.119.68
+190.248.136.229
+190.249.160.169
+190.253.230.54
+190.254.148.213
+190.3.36.105
+190.33.255.26
+190.37.117.144
+190.38.40.46
+190.39.158.1
+190.39.195.72
+190.4.1.150
+190.4.30.195
+190.40.230.185
+190.42.124.87
+190.42.184.52
+190.42.34.95
+190.43.120.91
+190.43.40.153
+190.44.84.131
+190.49.46.80
+190.5.117.153
+190.5.122.174
+190.5.92.18
+190.52.168.58
+190.52.175.76
+190.53.89.103
+190.57.144.102
+190.57.144.42
+190.57.169.226
+190.57.189.78
+190.6.203.124
+190.6.29.173
+190.6.35.203
+190.60.215.172
+190.60.234.131
+190.60.251.44
+190.60.4.91
+190.63.154.197
+190.63.182.230
+190.63.187.230
+190.64.135.122
+190.64.160.101
+190.7.144.202
+190.7.149.53
+190.73.0.234
+190.73.55.61
+190.74.169.21
+190.75.198.179
+190.75.222.40
+190.78.165.211
+190.78.82.216
+190.79.140.223
+190.79.143.123
+190.79.91.74
+190.81.177.26
+190.82.117.18
+190.82.117.20
+190.85.122.134
+190.85.146.156
+190.9.56.170
+190.9.59.198
+190.9.60.10
+190.90.122.26
+190.90.193.212
+190.90.218.253
+190.93.179.126
+190.94.247.254
+190.95.19.236
+190.95.7.235
+190.96.47.237
+190.96.91.243
+191.102.122.3
+191.102.125.74
+191.102.125.75
+191.102.84.196
+191.102.89.54
+191.103.252.169
+191.103.253.25
+191.103.253.89
+191.98.183.138
+191.98.183.139
+192.115.104.88
+192.116.226.69
+193.111.120.47
+193.153.38.221
+193.171.32.6
+193.173.119.83
+193.188.95.146
+193.194.69.155
+193.220.32.246
+193.251.1.244
+193.251.35.18
+193.30.164.3
+193.40.59.83
+193.52.195.6
+193.69.186.83
+193.86.86.86
+194.117.157.72
+194.149.220.21
+194.149.222.33
+194.176.176.82
+194.177.202.247
+194.30.228.83
+194.46.229.3
+194.55.138.53
+194.6.1.219
+194.79.113.83
+194.9.85.141
+194.90.179.13
+195.103.8.10
+195.135.236.215
+195.146.78.214
+195.160.188.163
+195.167.64.193
+195.209.224.91
+195.242.192.18
+195.246.155.219
+195.47.14.193
+195.54.22.74
+195.55.85.254
+195.76.242.227
+195.89.143.211
+195.97.171.76
+196.12.145.125
+196.20.7.74
+196.202.252.244
+196.203.172.166
+196.23.147.34
+196.23.52.170
+196.25.52.36
+196.27.116.60
+196.38.62.210
+196.40.43.34
+198.164.83.28
+198.49.131.250
+198.83.124.250
+199.193.10.202
+199.193.13.202
+199.216.215.21
+199.253.99.202
+2.136.85.132
+20.132.16.22
+200.101.13.202
+200.101.92.148
+200.102.191.228
+200.102.217.207
+200.104.104.91
+200.104.250.92
+200.105.148.74
+200.105.227.182
+200.107.29.70
+200.109.108.137
+200.109.119.126
+200.109.228.66
+200.109.72.53
+200.11.138.149
+200.110.13.169
+200.111.102.66
+200.111.121.18
+200.111.121.19
+200.111.121.21
+200.111.122.107
+200.112.216.5
+200.112.228.211
+200.112.70.53
+200.112.84.5
+200.114.104.4
+200.114.97.14
+200.115.25.118
+200.115.25.119
+200.115.25.120
+200.116.132.2
+200.116.209.58
+200.116.227.138
+200.116.227.99
+200.116.69.6
+200.117.240.56
+200.118.104.79
+200.118.168.237
+200.119.239.252
+200.119.56.48
+200.120.195.140
+200.120.224.207
+200.122.211.14
+200.123.102.149
+200.123.254.177
+200.123.50.43
+200.123.55.253
+200.125.202.198
+200.128.6.235
+200.135.246.2
+200.139.78.114
+200.14.126.36
+200.140.171.24
+200.142.99.166
+200.146.85.16
+200.148.230.217
+200.150.139.211
+200.152.107.56
+200.158.26.223
+200.159.216.247
+200.16.208.187
+200.160.244.246
+200.160.96.39
+200.161.108.72
+200.161.118.198
+200.161.125.6
+200.161.31.11
+200.161.81.98
+200.162.113.32
+200.165.140.104
+200.167.145.129
+200.167.86.209
+200.168.152.152
+200.171.17.23
+200.171.175.157
+200.171.232.140
+200.174.85.193
+200.174.85.195
+200.187.136.122
+200.192.210.98
+200.195.95.38
+200.2.125.90
+200.202.214.4
+200.204.121.196
+200.204.154.29
+200.204.235.123
+200.205.87.106
+200.206.46.178
+200.207.126.232
+200.207.48.252
+200.207.9.168
+200.209.175.243
+200.21.225.82
+200.21.24.79
+200.217.16.202
+200.217.53.234
+200.217.76.37
+200.221.10.104
+200.225.0.174
+200.232.184.253
+200.232.94.34
+200.233.77.95
+200.242.95.171
+200.245.35.131
+200.25.250.233
+200.252.201.144
+200.27.164.196
+200.29.191.151
+200.3.207.39
+200.30.101.2
+200.31.137.58
+200.31.42.3
+200.35.51.206
+200.37.231.66
+200.37.80.11
+200.4.253.27
+200.40.113.134
+200.41.230.102
+200.41.230.105
+200.42.225.106
+200.42.45.211
+200.43.138.196
+200.43.187.129
+200.44.217.52
+200.45.22.134
+200.45.32.150
+200.46.109.82
+200.48.106.34
+200.48.129.123
+200.49.181.162
+200.5.83.150
+200.50.170.38
+200.52.4.82
+200.54.103.76
+200.54.108.54
+200.54.194.12
+200.54.212.234
+200.55.208.203
+200.55.219.178
+200.59.9.18
+200.6.180.2
+200.6.225.122
+200.60.16.22
+200.61.19.208
+200.61.6.50
+200.65.129.1
+200.65.129.2
+200.67.117.246
+200.67.85.1
+200.68.34.99
+200.68.97.116
+200.69.245.33
+200.7.252.182
+200.72.187.74
+200.72.187.75
+200.74.158.86
+200.75.12.213
+200.75.9.35
+200.80.230.10
+200.81.172.98
+200.82.157.103
+200.83.4.60
+200.84.128.104
+200.84.138.166
+200.84.14.108
+200.84.150.113
+200.84.151.133
+200.84.181.210
+200.84.47.228
+200.85.123.154
+200.85.37.254
+200.85.59.250
+200.87.180.226
+200.87.43.50
+200.88.223.99
+200.89.129.99
+200.90.148.195
+200.93.43.43
+200.93.82.36
+200.94.92.230
+200.95.239.254
+200.96.193.100
+201.0.17.76
+201.1.113.10
+201.1.63.111
+201.10.42.166
+201.100.42.12
+201.116.199.243
+201.116.70.1
+201.12.116.112
+201.122.180.91
+201.13.169.167
+201.13.176.9
+201.13.187.229
+201.132.155.10
+201.132.155.70
+201.132.160.210
+201.132.162.254
+201.14.225.222
+201.144.14.229
+201.148.23.69
+201.15.143.25
+201.15.218.158
+201.15.30.1
+201.155.194.182
+201.16.232.37
+201.160.37.2
+201.165.55.14
+201.166.150.158
+201.166.23.226
+201.167.56.18
+201.17.163.70
+201.17.188.5
+201.172.123.9
+201.172.139.205
+201.172.194.92
+201.172.80.223
+201.173.158.27
+201.173.223.180
+201.173.240.145
+201.183.235.77
+201.184.224.98
+201.184.227.178
+201.184.237.194
+201.184.244.186
+201.184.250.76
+201.184.252.42
+201.184.72.34
+201.184.81.178
+201.184.86.218
+201.187.110.174
+201.190.181.76
+201.20.89.10
+201.208.117.43
+201.208.133.140
+201.208.155.57
+201.208.38.95
+201.208.43.80
+201.209.12.56
+201.209.173.54
+201.211.154.15
+201.211.189.154
+201.213.54.67
+201.216.213.201
+201.217.217.26
+201.217.245.108
+201.217.246.34
+201.219.184.227
+201.219.218.17
+201.219.218.18
+201.220.84.146
+201.222.52.30
+201.222.55.93
+201.222.99.12
+201.228.89.170
+201.229.208.2
+201.229.208.3
+201.238.239.88
+201.24.125.218
+201.240.212.1
+201.240.52.170
+201.242.120.234
+201.242.192.120
+201.243.173.251
+201.243.63.12
+201.245.190.38
+201.246.116.96
+201.249.52.100
+201.249.88.226
+201.251.126.164
+201.252.106.88
+201.252.14.124
+201.252.211.201
+201.253.124.65
+201.253.144.1
+201.253.9.185
+201.255.141.247
+201.255.178.224
+201.26.133.204
+201.26.169.10
+201.26.19.180
+201.26.200.105
+201.26.212.10
+201.26.8.186
+201.27.2.220
+201.3.184.222
+201.31.247.225
+201.39.29.50
+201.42.69.73
+201.43.31.164
+201.44.24.98
+201.45.188.169
+201.53.148.108
+201.53.73.44
+201.59.184.124
+201.66.27.218
+201.68.18.124
+201.68.227.8
+201.68.244.150
+201.68.77.129
+201.73.45.70
+201.73.73.130
+201.74.205.134
+201.75.20.103
+201.75.78.76
+201.76.141.210
+201.76.29.82
+201.80.187.222
+201.80.207.132
+201.86.70.162
+201.88.248.243
+201.93.128.110
+202.102.73.145
+202.104.189.20
+202.104.20.181
+202.105.138.19
+202.105.182.12
+202.105.182.13
+202.105.182.15
+202.105.182.16
+202.105.182.20
+202.105.182.33
+202.105.182.87
+202.105.230.226
+202.106.139.88
+202.108.122.38
+202.110.204.18
+202.114.66.170
+202.115.202.250
+202.12.73.20
+202.129.181.242
+202.134.202.251
+202.141.25.92
+202.143.140.250
+202.143.154.242
+202.145.3.101
+202.147.168.58
+202.153.41.102
+202.164.191.186
+202.168.193.131
+202.177.119.4
+202.188.222.2
+202.194.133.31
+202.194.202.7
+202.239.243.116
+202.29.137.147
+202.3.217.125
+202.39.6.27
+202.41.105.162
+202.41.181.24
+202.44.8.100
+202.54.169.233
+202.54.217.164
+202.54.61.99
+202.58.163.201
+202.58.86.8
+202.63.177.3
+202.65.43.172
+202.70.36.242
+202.70.88.138
+202.71.240.33
+202.78.225.1
+202.78.227.32
+202.80.127.29
+202.87.179.206
+202.9.136.40
+202.94.203.89
+202.94.212.199
+202.95.169.175
+202.98.141.200
+202.98.23.114
+202.98.23.116
+202.99.225.45
+202.99.29.27
+203.101.61.76
+203.110.240.22
+203.113.137.66
+203.113.34.239
+203.117.67.122
+203.123.240.112
+203.124.21.224
+203.129.53.177
+203.130.203.41
+203.149.32.30
+203.151.40.4
+203.155.16.130
+203.158.167.152
+203.160.1.103
+203.160.1.112
+203.160.1.121
+203.160.1.130
+203.160.1.66
+203.160.1.75
+203.160.1.85
+203.160.1.94
+203.162.183.222
+203.186.92.119
+203.193.138.148
+203.196.67.107
+203.197.37.46
+203.200.75.165
+203.202.203.8
+203.67.172.29
+203.69.244.194
+203.69.39.251
+203.76.185.189
+203.78.11.73
+203.82.52.210
+203.86.31.92
+204.111.219.182
+204.196.104.27
+204.85.72.128
+205.138.18.80
+205.200.37.111
+206.174.3.131
+206.230.106.206
+206.49.33.250
+206.51.224.46
+207.102.0.15
+207.161.20.188
+207.167.236.137
+207.181.207.36
+207.182.152.72
+207.192.207.240
+207.192.209.237
+207.248.102.18
+207.248.230.70
+207.249.163.139
+207.38.251.111
+207.50.148.37
+207.61.241.100
+207.61.38.67
+207.99.23.198
+208.107.124.142
+208.107.18.112
+208.110.73.34
+208.34.14.113
+208.34.14.165
+208.53.196.161
+208.53.199.48
+208.53.199.75
+208.62.125.146
+208.66.171.217
+208.77.219.76
+208.96.122.142
+208.96.133.198
+208.96.213.149
+208.98.17.40
+209.1.163.63
+209.124.242.193
+209.137.150.138
+209.145.114.173
+209.159.184.219
+209.159.204.250
+209.159.228.202
+209.159.229.219
+209.159.241.112
+209.17.186.25
+209.195.4.27
+209.211.7.12
+209.218.218.171
+209.4.229.39
+209.45.108.175
+209.45.48.205
+209.47.38.116
+209.79.65.6
+209.89.66.6
+21.11.27.110
+210.12.86.181
+210.163.167.162
+210.192.111.173
+210.204.118.194
+210.21.12.94
+210.219.227.52
+210.23.124.178
+210.240.54.8
+210.245.63.218
+210.245.80.10
+210.245.80.15
+210.254.8.52
+210.34.14.166
+210.4.10.134
+210.51.4.173
+210.52.15.210
+210.74.254.35
+210.8.92.2
+210.82.40.243
+210.86.181.202
+210.92.128.194
+210.96.65.4
+211.108.62.230
+211.114.116.60
+211.115.185.41
+211.115.185.42
+211.115.185.44
+211.138.198.6
+211.139.120.69
+211.140.138.39
+211.140.151.214
+211.140.192.186
+211.15.62.123
+211.161.197.182
+211.21.111.227
+211.233.21.166
+211.45.21.165
+211.76.175.5
+211.90.114.199
+211.90.22.106
+211.93.108.113
+211.99.188.218
+212.102.0.104
+212.116.219.202
+212.117.63.145
+212.119.69.187
+212.12.157.130
+212.123.91.61
+212.138.84.62
+212.15.44.9
+212.165.156.74
+212.17.86.109
+212.179.127.188
+212.225.233.154
+212.231.65.8
+212.24.238.155
+212.243.183.5
+212.38.100.62
+212.4.98.158
+212.44.61.185
+212.60.65.206
+212.72.120.135
+212.76.90.2
+212.93.193.82
+213.137.130.166
+213.154.216.55
+213.158.112.202
+213.16.133.130
+213.16.20.140
+213.171.255.2
+213.174.145.1
+213.174.145.193
+213.174.145.250
+213.180.131.135
+213.185.116.152
+213.186.116.57
+213.212.75.12
+213.231.139.130
+213.249.237.196
+213.25.170.98
+213.25.29.12
+213.250.162.237
+213.255.229.205
+213.27.152.15
+213.27.71.250
+213.4.106.85
+213.4.106.86
+213.55.87.105
+213.55.87.205
+213.82.91.94
+213.97.52.28
+216.114.194.18
+216.117.225.240
+216.119.183.110
+216.127.32.22
+216.168.43.11
+216.19.216.44
+216.217.98.100
+216.228.57.247
+216.23.162.169
+216.230.72.76
+216.241.14.94
+216.241.36.82
+216.36.141.205
+216.72.196.21
+216.72.63.198
+216.80.118.13
+216.93.253.150
+217.10.246.2
+217.10.246.4
+217.117.136.88
+217.126.5.224
+217.147.30.24
+217.153.114.66
+217.174.98.198
+217.218.243.61
+217.219.211.89
+217.31.51.76
+217.31.51.77
+217.70.56.161
+218.104.180.228
+218.118.100.86
+218.127.146.43
+218.14.227.197
+218.14.227.198
+218.15.63.46
+218.202.48.81
+218.206.194.247
+218.229.29.128
+218.241.81.219
+218.248.20.160
+218.248.31.212
+218.249.83.87
+218.252.37.227
+218.26.204.66
+218.28.46.250
+218.28.58.86
+218.5.133.146
+218.50.52.210
+218.56.32.230
+218.56.64.210
+218.56.64.211
+218.56.64.212
+218.56.64.213
+218.58.136.14
+218.64.88.30
+218.7.48.22
+218.70.172.2
+218.75.100.114
+218.75.25.137
+218.75.76.74
+218.75.83.98
+218.76.207.31
+218.9.114.85
+218.94.9.38
+218.97.194.94
+219.113.113.52
+219.191.64.95
+219.208.194.33
+219.22.30.44
+219.240.36.173
+219.240.36.175
+219.25.212.32
+219.34.2.65
+219.37.208.150
+219.43.150.92
+219.43.228.154
+219.58.72.191
+219.64.91.118
+220.128.189.35
+220.130.81.188
+220.173.139.172
+220.194.55.160
+220.225.225.148
+220.227.138.82
+220.227.47.2
+220.227.47.20
+220.227.47.6
+220.231.29.99
+220.33.204.136
+220.5.108.156
+220.53.245.8
+220.56.96.67
+220.58.24.40
+220.6.120.99
+220.70.2.137
+221.11.27.110
+221.120.196.138
+221.122.66.84
+221.130.177.147
+221.139.50.83
+221.16.4.11
+221.178.141.159
+221.192.132.194
+221.2.216.38
+221.202.118.17
+221.224.95.158
+221.233.134.87
+221.3.2.61
+221.6.62.90
+221.8.74.211
+222.124.172.220
+222.191.242.225
+222.221.6.144
+222.240.208.14
+222.247.62.195
+222.35.90.16
+222.68.206.11
+222.68.207.11
+222.73.205.27
+222.83.228.17
+222.83.228.34
+222.86.132.20
+222.92.116.39
+24.10.186.31
+24.10.84.226
+24.100.18.152
+24.108.129.42
+24.108.35.246
+24.11.124.76
+24.11.20.98
+24.11.68.191
+24.11.90.41
+24.111.38.223
+24.116.11.52
+24.116.206.33
+24.118.147.89
+24.118.240.6
+24.119.169.151
+24.119.54.123
+24.12.214.237
+24.12.3.143
+24.125.125.26
+24.125.155.7
+24.125.158.91
+24.125.244.207
+24.125.73.200
+24.125.77.116
+24.127.113.227
+24.13.108.167
+24.131.171.96
+24.136.244.198
+24.137.215.227
+24.139.68.242
+24.14.107.77
+24.14.112.139
+24.140.13.237
+24.143.226.7
+24.151.126.249
+24.153.249.240
+24.154.129.8
+24.156.135.87
+24.16.192.63
+24.161.131.67
+24.167.83.34
+24.168.26.236
+24.170.37.75
+24.170.82.144
+24.170.90.111
+24.174.246.62
+24.175.116.55
+24.175.131.67
+24.175.136.94
+24.175.147.159
+24.182.38.24
+24.185.121.80
+24.185.21.20
+24.188.121.167
+24.188.125.225
+24.188.251.54
+24.189.5.235
+24.190.104.34
+24.190.214.200
+24.192.240.240
+24.193.87.7
+24.197.130.9
+24.2.24.6
+24.2.69.26
+24.20.45.101
+24.205.202.45
+24.208.222.208
+24.208.37.143
+24.210.148.161
+24.211.221.167
+24.211.49.0
+24.215.67.225
+24.215.89.254
+24.217.148.83
+24.217.194.73
+24.22.86.147
+24.228.49.186
+24.23.182.99
+24.23.199.14
+24.23.29.41
+24.230.163.136
+24.230.181.207
+24.230.182.225
+24.230.46.187
+24.237.24.27
+24.238.20.152
+24.242.236.98
+24.250.66.218
+24.254.113.238
+24.254.28.207
+24.254.34.183
+24.255.169.250
+24.255.247.79
+24.3.105.116
+24.3.253.43
+24.30.58.77
+24.30.62.26
+24.30.90.20
+24.4.239.144
+24.44.199.190
+24.44.219.167
+24.44.95.243
+24.45.174.137
+24.46.74.16
+24.47.247.186
+24.59.34.24
+24.61.52.46
+24.67.14.108
+24.7.230.76
+24.70.39.70
+24.77.207.136
+24.77.22.225
+24.78.169.73
+24.78.186.206
+24.8.191.246
+24.83.40.206
+24.83.69.55
+24.85.205.35
+24.89.198.236
+24.89.218.113
+24.9.22.230
+24.90.184.242
+24.98.12.137
+24.98.204.26
+24.98.81.111
+24.98.99.4
+31.25.181.98
+37.46.158.141
+37.46.158.199
+38.123.201.17
+41.205.107.65
+41.210.252.11
+41.211.224.66
+41.211.232.39
+41.221.177.29
+45.4.88.66
+45.7.132.58
+46.37.120.155
+5.154.37.28
+5.40.117.250
+5.40.117.253
+5.61.212.42
+58.137.8.85
+58.17.3.2
+58.181.22.190
+58.221.41.86
+58.222.254.13
+58.29.56.2
+58.56.108.114
+58.68.51.46
+58.69.190.50
+58.8.150.146
+58.83.197.27
+59.120.104.60
+59.144.175.48
+59.165.1.214
+59.177.176.21
+59.36.98.154
+59.39.145.178
+59.42.250.145
+59.45.207.24
+59.56.174.199
+59.92.21.80
+59.92.3.208
+59.94.177.245
+59.94.38.39
+59.94.41.39
+59.95.205.216
+60.12.227.209
+60.191.246.17
+60.191.96.90
+60.213.185.214
+60.218.99.18
+60.247.2.241
+60.250.139.213
+60.250.27.242
+60.28.182.205
+60.28.209.8
+60.32.115.204
+60.49.216.82
+60.49.51.63
+60.5.108.142
+61.12.149.73
+61.120.148.32
+61.131.48.219
+61.133.196.36
+61.133.196.40
+61.135.158.109
+61.135.158.125
+61.135.158.129
+61.135.158.130
+61.138.130.229
+61.139.73.6
+61.142.169.98
+61.142.81.37
+61.144.109.96
+61.152.154.19
+61.153.140.106
+61.156.42.123
+61.159.214.215
+61.159.235.36
+61.166.68.71
+61.166.68.72
+61.167.117.83
+61.17.232.227
+61.172.246.180
+61.172.249.94
+61.172.249.96
+61.175.139.6
+61.175.226.78
+61.178.128.208
+61.180.73.66
+61.182.66.53
+61.187.187.28
+61.19.158.212
+61.19.78.44
+61.234.254.69
+61.236.87.104
+61.238.104.200
+61.244.119.198
+61.27.47.49
+61.32.11.130
+61.54.82.130
+61.6.163.30
+61.60.55.220
+61.8.73.59
+61.91.242.19
+62.103.76.22
+62.119.28.242
+62.15.205.71
+62.159.143.172
+62.164.251.180
+62.168.41.61
+62.192.132.126
+62.193.246.10
+62.215.195.85
+62.49.143.76
+62.76.32.166
+62.81.76.18
+62.90.57.24
+62.99.77.116
+63.119.14.20
+64.111.32.54
+64.114.203.21
+64.17.66.234
+64.179.170.189
+64.184.203.60
+64.203.49.117
+64.207.224.70
+64.30.123.252
+64.58.28.250
+64.66.192.61
+64.79.197.36
+64.79.199.35
+64.79.209.203
+64.83.209.110
+64.86.28.118
+64.94.19.10
+65.190.207.153
+65.191.10.80
+65.255.71.17
+65.255.74.9
+65.28.103.187
+65.28.107.26
+65.28.109.2
+65.28.8.13
+65.29.182.114
+65.29.85.76
+65.30.216.140
+65.30.92.48
+65.35.247.52
+65.7.5.33
+65.75.189.33
+65.87.170.39
+66.128.83.112
+66.131.89.59
+66.135.163.52
+66.158.95.37
+66.165.197.13
+66.166.1.181
+66.167.100.59
+66.167.228.62
+66.168.71.215
+66.177.219.202
+66.178.127.24
+66.197.167.90
+66.198.41.11
+66.199.247.42
+66.214.17.189
+66.214.213.172
+66.219.59.95
+66.225.182.65
+66.229.205.251
+66.233.165.31
+66.234.204.35
+66.244.214.230
+66.244.214.249
+66.25.114.65
+66.253.168.169
+66.29.36.95
+66.38.121.88
+66.41.14.103
+66.41.150.79
+66.41.189.96
+66.56.175.206
+66.56.183.16
+66.57.1.142
+66.57.230.14
+66.57.75.68
+66.67.106.227
+66.90.237.241
+66.91.101.52
+67.100.121.150
+67.149.165.201
+67.149.215.109
+67.163.161.226
+67.168.222.227
+67.182.204.248
+67.184.38.23
+67.188.156.177
+67.19.148.234
+67.191.141.209
+67.191.220.137
+67.201.77.7
+67.48.16.231
+67.48.22.73
+67.48.23.137
+67.49.150.210
+67.49.162.117
+67.60.113.96
+67.65.242.94
+67.69.254.240
+67.69.254.241
+67.69.254.242
+67.69.254.244
+67.69.254.245
+67.69.254.246
+67.69.254.247
+67.69.254.248
+67.69.254.249
+67.69.254.250
+67.69.254.251
+67.69.254.252
+67.69.254.254
+67.69.254.255
+67.80.47.215
+67.80.81.65
+67.81.185.139
+67.81.225.132
+67.81.235.37
+67.82.22.33
+67.84.115.34
+67.84.148.144
+67.84.35.131
+67.84.84.89
+67.86.193.156
+67.87.64.23
+67.9.1.214
+67.9.20.215
+67.9.23.8
+67.9.25.132
+67.9.255.2
+67.9.28.224
+67.9.5.59
+67.9.5.92
+68.1.131.15
+68.10.87.155
+68.100.213.42
+68.102.90.174
+68.104.55.221
+68.105.0.173
+68.105.12.164
+68.108.224.145
+68.108.23.74
+68.109.170.127
+68.11.145.150
+68.11.181.113
+68.11.181.82
+68.11.182.166
+68.11.226.141
+68.11.237.184
+68.11.249.230
+68.111.127.37
+68.111.158.24
+68.111.231.178
+68.111.57.10
+68.113.102.37
+68.114.149.164
+68.115.77.233
+68.117.211.122
+68.118.245.35
+68.12.148.195
+68.12.169.13
+68.13.220.63
+68.144.70.254
+68.148.10.200
+68.148.86.171
+68.149.66.202
+68.173.44.218
+68.184.185.242
+68.184.56.83
+68.192.166.141
+68.198.151.89
+68.198.72.147
+68.199.107.24
+68.199.140.101
+68.2.143.105
+68.201.24.46
+68.201.46.56
+68.205.170.214
+68.207.186.253
+68.207.187.211
+68.224.71.23
+68.227.129.204
+68.228.236.251
+68.229.158.96
+68.36.188.103
+68.42.247.66
+68.44.106.173
+68.45.42.160
+68.51.214.245
+68.52.31.249
+68.54.70.19
+68.55.215.207
+68.55.225.102
+68.59.217.62
+68.59.44.32
+68.60.168.230
+68.60.188.125
+68.60.189.199
+68.62.176.8
+68.62.218.171
+68.62.240.77
+68.63.48.36
+68.8.224.217
+68.8.227.134
+68.81.191.233
+68.83.114.81
+68.83.156.112
+68.84.126.225
+68.84.47.147
+68.9.156.171
+68.9.242.26
+68.97.121.200
+68.97.127.248
+68.97.206.212
+68.98.0.233
+69.113.232.218
+69.114.237.205
+69.114.82.211
+69.115.86.239
+69.116.109.16
+69.116.42.119
+69.118.237.19
+69.119.206.230
+69.122.153.0
+69.122.222.90
+69.123.43.126
+69.123.44.118
+69.126.24.134
+69.127.102.247
+69.127.115.255
+69.127.175.231
+69.136.136.125
+69.136.58.38
+69.136.70.21
+69.138.220.71
+69.138.46.194
+69.139.124.172
+69.139.167.203
+69.142.108.83
+69.142.201.72
+69.151.73.128
+69.154.135.199
+69.161.78.160
+69.180.245.32
+69.180.8.201
+69.181.89.167
+69.212.49.215
+69.224.140.241
+69.23.105.226
+69.23.122.70
+69.242.176.42
+69.242.220.173
+69.245.152.207
+69.245.52.76
+69.246.117.136
+69.246.123.195
+69.246.123.26
+69.246.16.28
+69.246.218.125
+69.246.45.182
+69.246.61.14
+69.249.151.19
+69.250.19.191
+69.250.8.55
+69.253.188.82
+69.253.79.204
+69.254.246.123
+69.255.105.108
+69.46.16.232
+69.47.174.178
+69.62.141.46
+69.64.38.125
+69.65.42.44
+69.7.105.30
+69.71.85.202
+69.71.95.69
+69.81.17.45
+69.86.12.190
+69.86.90.176
+69.88.35.197
+69.92.244.253
+69.92.76.23
+70.117.243.108
+70.118.212.66
+70.119.146.106
+70.123.89.57
+70.125.110.220
+70.127.205.107
+70.161.20.242
+70.161.72.245
+70.161.93.238
+70.172.242.76
+70.176.119.94
+70.176.124.127
+70.176.191.105
+70.177.51.135
+70.177.53.179
+70.177.61.32
+70.180.206.70
+70.180.62.153
+70.186.168.130
+70.186.174.186
+70.225.202.96
+70.238.144.197
+70.238.47.146
+70.44.209.4
+70.45.34.75
+70.64.225.85
+70.64.250.176
+70.64.252.25
+70.66.218.123
+70.67.105.211
+70.72.152.13
+70.74.213.38
+70.76.83.81
+70.78.22.121
+70.82.140.29
+70.86.138.210
+70.89.83.173
+70.95.110.195
+71.10.72.221
+71.11.157.62
+71.14.40.151
+71.14.94.250
+71.14.95.198
+71.178.155.167
+71.192.111.90
+71.192.233.196
+71.192.234.31
+71.194.0.41
+71.194.217.243
+71.197.189.88
+71.200.233.55
+71.201.60.149
+71.203.142.40
+71.203.162.68
+71.203.213.53
+71.205.102.196
+71.205.107.223
+71.205.109.70
+71.205.111.45
+71.205.113.223
+71.205.123.131
+71.205.238.140
+71.205.238.236
+71.205.37.198
+71.206.111.22
+71.207.56.148
+71.224.107.188
+71.224.87.71
+71.225.31.105
+71.229.16.100
+71.233.34.93
+71.235.86.254
+71.237.41.13
+71.237.98.13
+71.238.39.207
+71.239.246.54
+71.250.251.214
+71.252.182.73
+71.41.99.190
+71.57.11.142
+71.7.246.230
+71.8.7.200
+71.8.98.36
+71.80.99.54
+71.82.77.13
+71.82.9.16
+71.85.121.118
+71.86.144.144
+71.86.150.78
+71.86.152.122
+71.89.55.232
+71.90.230.116
+72.128.40.214
+72.135.0.206
+72.137.122.212
+72.140.93.121
+72.141.35.81
+72.172.203.25
+72.174.123.174
+72.174.161.51
+72.175.137.173
+72.178.207.128
+72.178.248.236
+72.190.118.76
+72.190.122.130
+72.196.135.11
+72.197.212.200
+72.200.193.225
+72.203.130.111
+72.207.200.241
+72.207.59.75
+72.213.190.99
+72.213.194.229
+72.216.12.173
+72.219.39.216
+72.222.172.142
+72.222.180.102
+72.227.236.241
+72.227.36.24
+72.229.126.142
+72.24.129.249
+72.24.145.190
+72.24.212.232
+72.39.117.31
+72.4.82.241
+72.52.96.51
+72.9.82.131
+74.105.84.51
+74.129.180.10
+74.131.139.186
+74.137.109.66
+74.141.111.159
+74.15.86.86
+74.171.2.153
+74.174.5.68
+74.193.33.187
+74.194.57.223
+74.194.61.154
+74.196.148.50
+74.197.219.75
+74.210.84.24
+74.222.1.99
+74.223.183.66
+74.54.156.73
+74.55.86.131
+74.56.176.187
+74.71.197.158
+74.73.99.82
+74.77.117.65
+75.109.46.197
+75.136.135.2
+75.153.242.91
+75.179.140.16
+75.180.26.184
+75.183.7.150
+75.184.41.3
+75.34.137.36
+75.64.250.79
+75.64.35.123
+75.65.64.163
+75.66.105.218
+75.71.42.32
+75.74.84.122
+75.81.22.134
+75.83.57.219
+75.85.136.141
+75.87.150.14
+75.87.189.110
+75.93.212.146
+75.94.80.132
+76.100.211.252
+76.102.95.54
+76.105.105.96
+76.106.127.211
+76.107.108.144
+76.107.116.228
+76.107.117.227
+76.107.125.246
+76.107.136.73
+76.107.136.74
+76.107.137.6
+76.107.142.104
+76.107.151.18
+76.107.156.235
+76.107.208.13
+76.107.38.217
+76.107.42.95
+76.107.44.181
+76.107.93.147
+76.109.161.247
+76.11.23.85
+76.110.119.179
+76.110.138.122
+76.110.211.162
+76.112.137.136
+76.112.150.1
+76.112.25.186
+76.113.8.160
+76.115.37.7
+76.116.82.97
+76.117.113.157
+76.117.201.40
+76.122.108.158
+76.123.128.94
+76.123.18.157
+76.124.36.104
+76.127.22.84
+76.160.138.68
+76.17.104.79
+76.17.69.212
+76.17.88.209
+76.170.85.232
+76.173.155.23
+76.173.95.124
+76.176.208.180
+76.178.184.139
+76.182.57.113
+76.183.112.143
+76.187.117.138
+76.20.228.53
+76.214.55.31
+76.22.0.234
+76.22.128.2
+76.244.189.250
+76.247.168.177
+76.25.236.65
+76.27.54.31
+76.28.1.186
+76.28.208.70
+76.28.250.36
+76.29.10.61
+76.29.243.55
+76.69.32.139
+76.85.159.130
+76.88.67.220
+76.89.19.252
+76.89.23.238
+76.9.42.163
+76.94.48.145
+76.98.47.148
+77.100.110.112
+77.100.241.213
+77.101.103.239
+77.101.103.91
+77.101.58.117
+77.102.121.34
+77.102.178.145
+77.102.220.82
+77.102.25.197
+77.103.130.91
+77.103.153.29
+77.103.254.43
+77.103.84.134
+77.226.237.178
+77.226.240.50
+77.237.91.60
+77.240.82.6
+77.242.169.70
+77.242.233.44
+77.242.33.5
+77.244.218.34
+77.41.140.70
+77.71.0.158
+77.78.1.119
+77.88.66.251
+77.96.105.84
+77.96.143.223
+77.97.103.232
+77.97.84.28
+77.98.146.168
+77.98.146.183
+77.99.11.82
+77.99.113.100
+77.99.162.166
+77.99.183.136
+77.99.30.244
+78.109.149.162
+78.129.239.35
+78.138.131.150
+78.154.132.241
+78.162.45.2
+78.224.128.22
+78.24.49.96
+78.31.64.74
+78.38.244.2
+78.38.244.9
+78.43.175.129
+79.152.2.193
+79.156.24.91
+80.0.76.158
+80.105.84.250
+80.127.3.115
+80.143.220.5
+80.150.14.123
+80.153.156.21
+80.192.214.147
+80.192.55.191
+80.192.75.52
+80.193.155.208
+80.193.158.197
+80.193.189.226
+80.193.72.145
+80.195.248.30
+80.195.3.136
+80.195.53.253
+80.227.1.101
+80.237.140.233
+80.237.38.77
+80.247.71.56
+80.25.120.104
+80.26.178.158
+80.34.164.229
+80.36.35.109
+80.4.59.69
+80.4.60.88
+80.88.242.32
+80.89.58.59
+80.90.160.194
+80.98.201.218
+81.101.145.245
+81.101.146.0
+81.104.137.210
+81.104.140.27
+81.104.254.45
+81.177.3.10
+81.18.116.70
+81.189.106.138
+81.192.153.91
+81.203.116.165
+81.21.97.68
+81.211.104.58
+81.214.184.188
+81.31.157.38
+81.34.252.228
+81.43.23.51
+81.52.167.82
+81.82.192.93
+81.96.121.31
+81.96.127.75
+81.97.147.154
+81.98.109.201
+82.0.100.211
+82.0.70.181
+82.11.211.202
+82.12.101.34
+82.12.118.67
+82.13.13.173
+82.13.85.245
+82.130.196.153
+82.130.196.97
+82.130.246.64
+82.146.33.201
+82.154.126.143
+82.158.219.108
+82.200.165.143
+82.21.1.166
+82.21.184.178
+82.21.51.247
+82.22.138.43
+82.230.7.246
+82.234.51.250
+82.238.32.72
+82.239.187.75
+82.24.15.141
+82.24.250.31
+82.245.149.3
+82.28.185.247
+82.28.30.130
+82.29.230.10
+82.3.162.235
+82.3.225.58
+82.3.97.212
+82.32.221.58
+82.33.108.2
+82.33.117.189
+82.33.168.194
+82.33.46.103
+82.33.67.71
+82.34.108.122
+82.34.224.141
+82.35.201.216
+82.35.243.181
+82.35.91.170
+82.36.209.11
+82.36.86.70
+82.37.169.145
+82.38.36.40
+82.39.199.238
+82.4.211.107
+82.4.47.67
+82.40.215.66
+82.40.28.187
+82.40.48.179
+82.41.10.6
+82.41.198.251
+82.41.21.126
+82.41.221.10
+82.41.4.227
+82.41.5.12
+82.41.56.62
+82.41.57.26
+82.42.57.203
+82.43.58.68
+82.43.63.99
+82.44.235.63
+82.44.34.27
+82.44.97.222
+82.45.110.245
+82.45.117.238
+82.45.253.25
+82.45.254.205
+82.45.59.203
+82.46.144.165
+82.46.169.181
+82.46.23.204
+82.46.44.15
+82.47.59.57
+82.5.60.63
+82.6.16.219
+82.6.69.14
+82.7.105.26
+82.76.17.46
+82.8.80.191
+82.9.52.183
+83.100.149.29
+83.111.81.109
+83.142.23.194
+83.17.123.186
+83.2.212.9
+83.218.188.208
+83.220.195.232
+83.231.34.133
+83.231.34.192
+83.231.34.236
+83.236.157.231
+83.36.162.217
+83.61.22.207
+83.64.115.103
+83.70.106.206
+83.85.27.225
+83.96.39.196
+84.12.135.98
+84.194.92.1
+84.198.148.132
+84.198.202.74
+84.204.73.154
+84.235.0.182
+84.236.171.66
+84.247.24.127
+84.255.246.20
+84.67.132.233
+84.71.161.123
+85.114.131.54
+85.131.161.84
+85.134.160.128
+85.142.20.122
+85.168.233.221
+85.214.52.253
+85.214.59.79
+85.219.5.109
+85.24.89.199
+85.31.91.114
+85.70.156.138
+85.84.213.189
+86.0.224.116
+86.10.109.253
+86.10.147.26
+86.101.185.109
+86.101.185.112
+86.101.185.97
+86.101.185.99
+86.105.181.238
+86.109.100.80
+86.11.208.239
+86.12.57.51
+86.12.7.19
+86.125.142.141
+86.15.193.138
+86.2.31.207
+86.20.87.54
+86.21.200.186
+86.22.7.232
+86.24.213.144
+86.25.180.145
+86.3.40.90
+86.4.20.251
+86.4.25.128
+86.42.180.157
+86.42.243.36
+86.46.156.172
+86.61.76.7
+86.9.124.75
+87.116.164.85
+87.120.67.39
+87.252.3.67
+87.66.29.96
+87.86.13.29
+87.94.43.58
+88.104.209.63
+88.104.67.106
+88.108.166.245
+88.110.83.81
+88.12.16.211
+88.165.169.130
+88.171.218.44
+88.172.20.212
+88.173.201.9
+88.173.228.213
+88.174.252.233
+88.183.152.141
+88.191.63.104
+88.191.63.27
+88.191.66.131
+88.191.69.101
+88.191.98.15
+88.198.57.182
+88.2.237.249
+88.208.219.155
+88.22.10.27
+88.26.196.190
+88.5.211.84
+89.140.79.175
+89.146.71.82
+89.163.30.175
+89.19.172.22
+89.206.8.242
+89.21.137.70
+89.212.253.19
+89.234.27.15
+89.241.213.95
+89.248.194.212
+89.29.195.27
+89.39.142.121
+89.96.169.141
+90.155.218.74
+90.157.115.140
+90.173.78.226
+90.199.136.7
+91.110.151.89
+91.121.147.12
+91.121.48.207
+91.121.91.61
+91.151.106.127
+91.203.136.191
+91.235.51.238
+91.235.51.247
+91.78.100.114
+92.233.166.55
+92.233.226.34
+92.233.3.150
+92.234.144.16
+92.235.253.182
+92.236.102.208
+92.236.137.151
+92.236.16.51
+92.236.18.113
+92.236.222.129
+92.236.249.98
+92.236.26.72
+92.237.70.251
+92.237.9.240
+92.238.148.101
+92.238.184.16
+92.238.25.211
+92.238.40.83
+92.239.120.214
+92.243.17.151
+92.63.49.201
+92.64.178.98
+92.9.76.236
+93.156.180.97
+93.184.0.20
+93.92.34.238
+94.102.60.89
+94.23.192.228
+94.23.81.70
+94.25.81.45
+95.62.147.8
+96.21.139.56
+96.28.116.40
+96.28.160.240
+96.3.152.82
+96.3.172.29
+97.85.152.126
+97.87.65.118
+97.91.188.113
+97.97.255.95
+98.126.15.16
+98.126.15.27
+98.126.27.165
+98.141.23.139
+98.155.147.62
+98.16.253.47
+98.163.204.145
+98.164.75.175
+98.165.245.250
+98.166.26.87
+98.169.171.231
+98.181.60.131
+98.181.63.133
+98.192.95.181
+98.197.219.216
+98.202.107.151
+98.202.188.75
+98.204.164.207
+98.206.20.88
+98.208.46.176
+98.210.139.101
+98.210.147.111
+98.223.204.15
+98.229.212.211
+98.230.6.223
+98.233.228.159
+98.240.186.255
+98.243.17.13
+98.244.161.239
+98.28.33.20
+99.155.153.203
+99.199.237.158
+99.225.136.21
+99.228.104.199
+99.232.137.243
+99.232.189.8
+99.237.129.44
+99.242.140.117
+99.247.210.73
+99.254.157.217
+99.254.203.191
+103.94.125.244
+177.126.218.67
+176.122.98.51
+196.27.116.162
+93.188.45.157
+103.37.30.66
+75.149.141.145
+160.202.157.254
+84.10.1.82
+181.29.65.2
+75.71.253.42
+117.74.124.129
+79.106.41.15
+5.175.69.113
+93.123.196.160
+159.255.165.221
+200.98.141.76
+108.61.191.181
+195.178.207.241
+176.111.33.152
+202.142.169.123
+95.78.113.84
+194.187.216.182
+178.57.82.102
+45.6.100.90
+68.15.42.194
+186.221.152.223
+46.99.255.235
+180.183.221.144
+91.214.179.24
+110.37.201.75
+128.199.239.109
+200.29.101.77
+31.211.130.169
+72.47.105.234
+185.29.255.195
+185.5.183.101
+175.111.182.153
+103.225.174.13
+140.227.70.107
+188.191.29.15
+95.137.240.222
+85.187.194.13
+91.186.121.19
+181.114.56.242
+105.22.72.26
+81.82.200.134
+45.71.240.82
+222.124.2.186
+91.92.79.137
+190.147.208.143
+180.250.165.200
+79.173.97.45
+212.90.180.154
+85.172.109.18
+69.85.70.37
+94.102.124.139
+217.126.85.147
+62.213.57.218
+12.52.30.83
+41.164.31.154
+36.72.34.50
+202.6.224.52
+139.255.95.194
+46.19.47.114
+24.134.35.197
+190.85.70.110
+202.91.82.81
+213.174.10.58
+185.247.136.198
+201.190.190.250
+46.40.7.131
+89.165.218.82
+170.239.46.145
+190.185.180.166
+103.83.205.57
+95.93.98.73
+36.89.143.185
+202.52.126.3
+212.46.220.214
+201.247.175.50
+191.102.64.15
+27.123.221.51
+188.17.149.0
+176.99.110.182
+103.197.49.14
+103.106.119.154
+89.31.45.115
+93.187.161.119
+103.254.209.68
+195.78.101.162
+187.19.62.7
+197.232.51.81
+162.253.153.80
+82.144.130.13
+14.1.102.218
+36.89.53.195
+170.254.229.154
+139.0.29.18
+78.107.250.181
+89.135.125.133
+177.85.91.40
+37.187.116.199
+96.74.196.249
+85.175.226.106
+103.218.26.110
+197.253.67.68
+49.236.220.238
+145.239.86.210
+170.80.86.1
+95.46.1.130
+90.150.181.35
+77.247.88.10
+193.93.49.193
+181.143.51.50
+196.192.185.142
+109.74.50.14
+106.12.32.43
+91.197.204.139
+189.205.61.147
+103.241.205.66
+212.164.234.207
+83.223.132.172
+31.28.0.204
+81.16.246.44
+69.206.51.199
+76.10.246.164
+185.15.0.187
+85.105.222.29
+77.242.18.96
+181.39.165.155
+197.210.152.38
+31.45.246.187
+119.40.87.94
+202.179.186.238
+37.252.65.183
+93.170.113.241
+103.232.67.18
+154.117.159.226
+66.210.170.9
+138.41.25.163
+69.51.6.201
+36.89.235.35
+78.130.241.7
+109.230.60.3
+176.192.124.98
+201.150.149.79
+87.117.1.150
+2.92.106.53
+178.134.79.18
+185.162.62.206
+125.236.241.235
+118.144.114.134
+118.91.181.153
+193.85.30.78
+170.79.9.54
+190.104.215.14
+31.22.29.229
+209.206.113.193
+37.53.83.40
+36.67.8.245
+200.13.243.178
+82.114.68.58
+154.117.157.5
+193.218.149.91
+85.66.27.165
+185.75.206.131
+154.119.49.238
+84.42.56.237
+95.165.182.18
+181.129.148.138
+193.43.95.139
+83.19.160.122
+185.98.233.39
+91.202.207.110
+191.7.212.178
+80.211.109.96
+103.194.250.182
+188.242.249.116
+181.143.36.163
+92.51.75.203
+45.234.16.138
+183.88.174.196
+206.189.217.206
+80.90.25.160
+185.62.188.84
+95.64.253.177
+91.102.80.82
+139.255.113.98
+92.86.32.150
+87.229.89.144
+196.44.98.162
+159.192.97.83
+45.221.72.58
+85.100.108.84
+81.161.67.240
+83.208.168.199
+213.6.139.42
+24.139.73.82
+131.72.96.202
+85.200.245.65
+95.79.116.84
+181.129.45.154
+110.136.119.140
+195.222.61.29
+91.93.168.227
+178.140.116.185
+83.146.67.32
+159.203.174.2
+182.16.173.74
+5.188.102.81
+27.123.1.46
+209.203.6.246
+176.215.197.128
+109.188.64.248
+27.116.60.250
+74.93.145.1
+185.190.149.34
+212.96.201.128
+24.113.168.225
+103.87.236.154
+92.247.187.132
+24.226.159.195
+45.64.158.150
+187.180.18.52
+141.105.35.11
+185.141.11.118
+145.239.90.169
+186.225.50.211
+114.57.238.254
+177.10.21.154
+103.206.230.26
+83.169.214.53
+46.238.248.116
+193.178.190.173
+182.52.134.121
+177.39.56.223
+217.61.172.12
+95.167.241.242
+85.133.207.14
+103.111.83.26
+193.107.228.222
+80.106.247.145
+91.189.131.114
+184.69.57.210
+194.125.224.43
+200.75.204.150
+43.255.114.53
+111.119.225.168
+183.91.66.210
+103.65.192.211
+181.48.203.198
+195.112.122.197
+185.91.166.83
+190.42.32.154
+124.191.118.110
+37.57.163.30
+173.219.56.28
+94.45.155.45
+5.39.79.90
+181.196.50.130
+35.184.125.195
+104.248.122.203
+181.28.17.171
+178.150.191.73
+187.28.39.146
+109.236.211.171
+86.120.46.88
+103.112.9.31
+85.106.7.4
+41.50.88.56
+5.56.122.183
+103.100.80.42
+43.245.186.105
+140.190.17.195
+45.114.68.123
+5.53.114.152
+42.115.72.254
+203.82.197.34
+182.52.51.13
+103.255.234.189
+37.150.188.120
+93.170.119.242
+93.86.163.238
+45.126.46.140
+36.66.61.155
+188.32.95.148
+202.148.2.254
+77.232.137.35
+90.154.120.202
+85.207.99.213
+36.89.132.210
+156.236.71.29
+195.138.91.102
+84.241.41.150
+112.78.143.26
+103.254.59.122
+93.87.41.14
+170.178.151.250
+197.149.128.56
+167.114.250.199
+223.197.56.102
+52.68.71.75
+103.103.182.19
+196.203.55.18
+41.60.216.43
+180.94.87.157
+103.5.172.182
+103.251.176.106
+77.242.26.57
+130.0.31.226
+91.187.85.98
+105.174.18.118
+105.174.19.194
+181.170.223.14
+200.91.48.20
+5.77.240.130
+37.252.68.84
+109.75.34.188
+35.194.213.161
+104.199.234.56
+115.70.2.73
+101.167.169.229
+202.174.46.113
+178.189.11.134
+77.119.245.150
+91.133.123.12
+109.127.9.96
+85.132.18.222
+91.135.241.110
+64.150.235.214
+103.216.59.81
+49.0.39.153
+115.127.63.10
+63.175.159.29
+37.17.12.131
+86.57.181.122
+78.20.210.244
+81.82.209.76
+94.226.211.106
+160.238.136.125
+138.185.76.78
+160.238.136.121
+41.74.9.238
+41.86.244.89
+201.131.41.254
+181.114.115.105
+81.93.93.251
+81.93.73.27
+185.12.79.59
+83.143.31.254
+83.143.26.70
+129.205.201.56
+201.74.174.141
+177.136.5.166
+187.108.114.108
+95.158.153.145
+89.106.101.191
+79.110.125.201
+41.216.148.140
+154.73.40.70
+196.2.15.144
+196.2.10.56
+111.118.150.193
+119.82.253.32
+103.239.54.188
+41.204.84.161
+169.239.40.1
+24.122.184.158
+197.149.128.233
+169.239.123.69
+200.54.44.140
+179.57.108.159
+200.27.66.222
+101.76.214.72
+221.218.102.146
+190.85.153.139
+201.236.248.216
+200.58.213.178
+197.255.179.179
+41.75.76.75
+41.190.232.158
+41.243.13.50
+170.81.34.22
+186.26.121.98
+186.4.4.90
+195.29.45.100
+213.147.102.122
+194.30.157.117
+31.209.104.71
+193.86.125.62
+84.42.202.253
+89.111.104.109
+212.112.129.203
+158.248.219.169
+93.176.85.228
+196.201.206.219
+154.66.245.47
+199.127.197.12
+190.166.249.44
+190.211.182.7
+190.122.97.138
+180.189.167.34
+103.55.48.170
+181.211.97.18
+201.183.249.226
+186.178.10.20
+197.51.146.78
+41.65.99.133
+200.89.87.242
+168.227.20.27
+190.53.46.134
+105.235.235.156
+95.153.30.58
+80.79.114.240
+95.210.45.189
+130.117.175.134
+130.117.169.119
+95.216.160.51
+95.216.95.226
+163.172.146.169
+41.78.243.198
+41.78.243.218
+31.192.17.25
+188.121.193.44
+185.164.111.197
+46.4.24.166
+94.16.120.75
+102.177.101.9
+41.139.51.98
+62.103.22.100
+185.186.84.66
+201.216.168.238
+190.115.9.113
+160.119.130.10
+41.242.90.178
+160.119.129.42
+186.1.206.99
+190.185.119.91
+190.6.205.134
+122.115.78.240
+37.191.6.61
+109.74.61.67
+46.107.226.220
+27.0.183.67
+103.194.250.110
+219.91.142.237
+36.89.75.57
+36.37.124.226
+185.129.212.4
+212.86.75.9
+37.238.132.186
+37.237.63.82
+89.101.215.90
+52.169.139.131
+109.226.17.8
+109.226.26.134
+192.116.49.15
+212.66.97.185
+185.26.65.82
+208.131.186.162
+74.116.59.8
+208.163.39.218
+61.113.193.98
+92.46.54.114
+91.185.2.102
+41.139.141.254
+217.21.125.225
+41.90.102.34
+222.121.116.26
+27.255.91.146
+94.128.135.77
+158.181.171.9
+92.245.114.23
+158.181.19.120
+188.112.142.182
+195.13.161.141
+185.142.43.35
+185.104.252.10
+185.9.137.114
+154.66.109.182
+41.191.204.146
+41.191.205.29
+197.215.217.122
+196.250.176.67
+197.215.217.150
+62.68.34.86
+91.187.188.115
+78.157.79.120
+104.244.72.171
+195.218.3.241
+95.86.56.113
+92.55.84.202
+62.162.106.49
+41.77.23.238
+196.216.13.27
+41.190.95.66
+154.66.122.142
+211.24.98.29
+182.54.207.74
+217.64.109.231
+197.155.158.22
+196.200.60.142
+212.56.152.162
+88.203.36.209
+41.72.213.22
+41.72.192.170
+197.231.186.148
+148.243.240.156
+177.234.0.218
+93.116.185.57
+95.65.1.200
+89.28.101.79
+202.21.124.226
+202.131.248.66
+196.92.3.193
+165.90.67.102
+196.3.97.86
+196.3.97.68
+203.81.74.46
+122.248.100.13
+196.20.12.29
+196.20.12.9
+196.20.12.41
+103.1.94.213
+103.235.199.33
+202.166.196.34
+185.179.204.210
+185.179.204.226
+185.179.204.175
+190.4.186.20
+190.2.132.17
+203.147.79.184
+202.27.212.17
+114.134.164.166
+202.49.183.168
+165.98.135.6
+186.1.6.62
+197.234.45.230
+105.112.83.35
+155.93.122.186
+195.204.130.149
+81.166.242.208
+46.183.169.64
+125.209.118.42
+202.142.191.75
+213.6.69.113
+213.6.136.118
+213.6.198.230
+190.218.77.236
+179.63.195.140
+103.103.182.22
+203.83.20.53
+181.40.40.166
+190.128.228.54
+190.52.177.128
+200.37.16.253
+45.5.56.62
+202.57.63.206
+210.4.97.193
+111.125.87.199
+195.205.218.53
+46.238.120.149
+93.108.234.238
+217.129.163.102
+193.126.23.235
+24.139.244.238
+192.254.109.11
+65.38.222.162
+95.76.196.92
+82.79.83.78
+95.154.72.55
+95.31.13.55
+41.242.140.157
+196.223.246.11
+212.69.18.150
+178.222.246.55
+154.70.175.155
+41.86.54.160
+41.86.57.65
+196.216.220.204
+196.216.220.130
+150.107.124.32
+150.107.124.37
+150.107.124.35
+213.81.178.97
+86.110.229.38
+5.22.154.13
+188.230.234.67
+77.38.21.145
+77.94.144.162
+103.21.231.132
+103.21.231.131
+103.21.230.67
+41.79.198.36
+154.73.45.206
+41.162.53.130
+196.22.229.210
+41.193.101.122
+160.119.211.50
+69.63.67.44
+160.119.210.180
+84.217.82.227
+78.73.14.128
+82.145.149.5
+217.11.47.9
+179.43.144.19
+177.154.139.196 \ No newline at end of file
diff --git a/cgi/quotes.conf b/cgi/quotes.conf
new file mode 100644
index 0000000..faf5221
--- /dev/null
+++ b/cgi/quotes.conf
@@ -0,0 +1,13 @@
+Eres una buena persona.
+Fue un mensaje de calidad.
+キタ━━━━━\( ゚∀゚ )/━━━━━!!!
+Te invitaría a un café.
+Plataformas del futuro para la web 1.0.
+Plataformas del pasado para la web 2.0.
+Suenas como un bot muy desarrollado.
+Elegiste bien. Elegiste calidad.
+Gracias por usar Internet.
+Gracias por tu papiro.
+`·.¸¸.·´´¯`··._.·[GrAcIaS pOr El PoSt]`·.¸¸.·´´¯`··._.·
+Ha sido un éxito.
+Funcionó. \ No newline at end of file
diff --git a/cgi/template.py b/cgi/template.py
new file mode 100644
index 0000000..0a7c530
--- /dev/null
+++ b/cgi/template.py
@@ -0,0 +1,117 @@
+# coding=utf-8
+import tenjin
+import random
+import re
+from tenjin.helpers import * # Used when templating
+
+from settings import Settings
+from database import *
+
+def renderTemplate(template, template_values={}, mobile=False, noindex=False):
+ """
+ Run Tenjin on the supplied template name, with the extra values
+ template_values (if supplied)
+ """
+ values = {
+ "title": Settings.NAME,
+ "board": None,
+ "board_name": None,
+ "board_long": None,
+ "is_page": "false",
+ "noindex": None,
+ "replythread": 0,
+ "home_url": Settings.HOME_URL,
+ "boards_url": Settings.BOARDS_URL,
+ "images_url": Settings.IMAGES_URL,
+ "static_url": Settings.STATIC_URL,
+ "cgi_url": Settings.CGI_URL,
+ "banner_url": None,
+ "banner_width": None,
+ "banner_height": None,
+ "disable_name": None,
+ "disable_subject": None,
+ "styles": Settings.STYLES,
+ "styles_default": Settings.STYLES_DEFAULT,
+ "txt_styles": Settings.TXT_STYLES,
+ "txt_styles_default": Settings.TXT_STYLES_DEFAULT,
+ "pagenav": "",
+ "reports_enable": Settings.REPORTS_ENABLE,
+ "force_css": ""
+ }
+
+ engine = tenjin.Engine(pp=[tenjin.TrimPreprocessor(True)])
+ board = Settings._.BOARD
+
+ #if board:
+ if template in ["board.html", "threadlist.html", "catalog.html", "kako.html", "paint.html"] or template[0:3] == "txt":
+ # TODO HACK
+ if board['dir'] == 'world' and not mobile and (template == 'txt_board.html' or template == 'txt_thread.html'):
+ template = template[:-4] + 'en.html'
+ elif board['dir'] == '2d' and template == 'board.html' and not mobile:
+ template = template[:-4] + 'jp.html'
+ elif board['dir'] == '0' and template == 'board.html' and not mobile:
+ template = template[:-4] + '0.html'
+
+ try:
+ banners = Settings.banners[board['dir']]
+ if banners:
+ banner_width = Settings.banners[board['dir']]
+ banner_height = Settings.banners[board['dir']]
+ except KeyError:
+ banners = Settings.banners['default']
+ banner_width = Settings.banners['default']
+ banner_height = Settings.banners['default']
+
+ values.update({
+ "board": board["dir"],
+ "board_name": board["name"],
+ "board_long": board["longname"],
+ "board_type": board["board_type"],
+ "oek_finish": 0,
+ "disable_name": (board["disable_name"] == '1'),
+ "disable_subject": (board["disable_subject"] == '1'),
+ "default_subject": board["subject"],
+ "postarea_desc": board["postarea_desc"],
+ "postarea_extra": board["postarea_extra"],
+ "allow_images": (board["allow_images"] == '1'),
+ "allow_image_replies": (board["allow_image_replies"] == '1'),
+ "allow_noimage": (board["allow_noimage"] == '1'),
+ "allow_spoilers": (board["allow_spoilers"] == '1'),
+ "allow_oekaki": (board["allow_oekaki"] == '1'),
+ "archive": (board["archive"] == '1'),
+ "force_css": board["force_css"],
+ "noindex": (board["secret"] == '1'),
+ "useid": board["useid"],
+ "maxsize": board["maxsize"],
+ "maxage": board["maxage"],
+ "maxdimensions": board["thumb_px"],
+ "supported_filetypes": board["filetypes_ext"],
+ "prevrange": '',
+ "nextrange": '',
+ })
+ else:
+ banners = Settings.banners['default']
+ banner_width = Settings.banners['default']
+ banner_height = Settings.banners['default']
+
+ if Settings.ENABLE_BANNERS:
+ if len(banners) > 1:
+ random_number = random.randrange(0, len(banners))
+ BANNER_URL = Settings.banners_folder + banners[random_number][0]
+ BANNER_WIDTH = banners[random_number][1]
+ BANNER_HEIGHT = banners[random_number][2]
+ else:
+ BANNER_URL = Settings.banners_folder + banners[0][0]
+ BANNER_WIDTH = banners[0][1]
+ BANNER_HEIGHT = banners[0][2]
+
+ values.update({"banner_url": BANNER_URL, "banner_width": BANNER_WIDTH, "banner_height": BANNER_HEIGHT})
+
+ values.update(template_values)
+
+ if mobile:
+ template_folder = "templates/mobile/"
+ else:
+ template_folder = "templates/"
+
+ return engine.render(template_folder + template, values) \ No newline at end of file
diff --git a/cgi/templates/anarkia.html b/cgi/templates/anarkia.html
new file mode 100644
index 0000000..3ded9da
--- /dev/null
+++ b/cgi/templates/anarkia.html
@@ -0,0 +1,329 @@
+<?py include('templates/base_top.html') ?>
+<?py from tenjin.helpers.html import * ?>
+<style>.anarkiahead {width:1000px; text-align:left}
+.anarkiahead h2 {margin-top: 0}
+.anarkiamenu a {font-size:20pt;display:inline-block;width:300px;padding:10px 0}
+.logs {font-size:small;max-height:300px;overflow-y:auto;width:600px}
+.long {white-space:nowrap}
+.full {width:100%}
+.return {font-size:24pt}</style>
+<center>
+<div class="replymode" style="font-size:26pt;color:red;font-weight:bold">ⒶⒶⒶⒶⒶⒶⒶⒶⒶⒶⒶⒶⒶⒶ</div>
+<br />
+<?py if mode == 0: ?>
+ <div class="anarkiahead">
+ <h2 style="border-bottom:1px solid;width:100%;">Anarkía @ B.a.I.</h2>
+ <p>Anarkía es una sección especial sin moderación y con acceso libre a su panel de administración.</p>
+ <ul>
+ <li>El staff de B.a.I. no interferirá de ninguna manera en esta sección y cualquiera es libre de modificar
+ sus parámetros, de eliminar mensajes o banear usuarios dentro de ella.</li>
+ <li>Los hilos de otras secciones que sean eliminados por su baja calidad, denuncias u otra razón, caerán por defecto a esta sección.</li>
+ <li>Los bans en esta sección son independientes del resto del sitio. Es decir, usuarios baneados en BaI son libres de usar esta sección.</li>
+ <li>Cualquier problema en su funcionamiento por favor reportar en la sección <a href="/bai/">Meta</a>.</li>
+ </div>
+ <div class="anarkiamenu">
+ <a href="#{cgi_url}anarkia/opt"><img src="#{boards_url}anarkia/opt.jpg" width="250" height="175"><br />Opciones generales</a>
+ <a href="#{cgi_url}anarkia/mod"><img src="#{boards_url}anarkia/mod.jpg" width="250" height="175"><br />Panel de moderación</a>
+ <a href="#{cgi_url}anarkia/css"><img src="#{boards_url}anarkia/css.jpg" width="250" height="175"><br />Editar CSS</a>
+ <br />
+ <a href="#{cgi_url}anarkia/emojis"><img src="#{boards_url}anarkia/emojis.jpg" width="250" height="175"><br />Emojis</a>
+ <a href="#{cgi_url}anarkia/bans"><img src="#{boards_url}anarkia/bans.jpg" width="250" height="175"><br />Bans</a>
+ <a href="#{cgi_url}anarkia/type"><img src="#{boards_url}anarkia/type.jpg" width="250" height="175"><br />Cambiar tipo de board</a>
+ </div>
+ <hr />
+ <input type="hidden" name="board" value="anarkia" />
+ <div class="logs">
+ <table class="managertable full">
+ <tr><th colspan="2">Logs</th></tr>
+ <tr><th>Fecha</th><th class="full">Acción</th></tr>
+ <?py for log in logs: ?>
+ <tr><td class="date" data-unix="${log['timestamp']}">${log['timestamp_formatted']}</td><td>${log['action']}</td></tr>
+ <?py #endfor ?>
+ </table>
+ </div>
+ <hr /><a href="#{boards_url}anarkia" class="return">Volver a la sección</a>
+<?py elif mode == 1: ?>
+<div class="replymode">Opciones de Board</div>
+<form action="#{cgi_url}anarkia/opt" method="post">
+<table>
+ <tr>
+ <td class="postblock">Nombre de sección</td>
+ <td><input type="text" name="longname" size="50" value="${boardopts['longname']}" maxlength="128" class="full" /></td>
+ </tr>
+ <tr>
+ <td class="postblock">Descripción</td>
+ <td>
+ <textarea id="patop" name="postarea_desc" rows="10" cols="50" class="full" oninput="pvw('patop')">${boardopts['postarea_desc']}</textarea>
+ <div id="p_patop" style="border:1px dotted gray;width:100%;"></div>
+ </td>
+ </tr>
+ <tr>
+ <td class="postblock">Caja extra</td>
+ <td><textarea name="postarea_extra" rows="5" cols="50" class="full">${boardopts['postarea_extra']}</textarea></td>
+ </tr>
+ <tr>
+ <td class="postblock">Nombre por defecto</td>
+ <td><input type="text" name="anonymous" size="50" maxlength="128" value="${boardopts['anonymous']}" class="full" /></td>
+ </tr>
+ <tr>
+ <td class="postblock">Título por defecto</td>
+ <td><input type="text" name="subject" size="50" maxlength="64" value="${boardopts['subject']}" class="full" /></td>
+ </tr>
+ <tr>
+ <td class="postblock">Mensaje por defecto</td>
+ <td><input type="text" name="message" size="50" maxlength="128" value="${boardopts['message']}" class="full" /></td>
+ </tr>
+ <tr>
+ <td class="postblock">ID</td>
+ <td>
+ <select name="useid" class="full">
+ <option value="0">Desactivado</option>
+ <option value="1"#{selected(boardopts['useid'] == '1')}>Activado</option>
+ <option value="2"#{selected(boardopts['useid'] == '2')}>Activado siempre</option>
+ <option value="3"#{selected(boardopts['useid'] == '3')}>Activado siempre, detallado</option>
+ </select>
+ </td>
+ </tr>
+ <tr>
+ <td class="postblock">Desactivar nombre</td>
+ <td><input type="checkbox" name="disable_name" id="noname" value="1"#{checked(boardopts['disable_name'] == '1')} /><label for="noname"></label></td>
+ </tr>
+ <tr>
+ <td class="postblock">Desactivar asunto</td>
+ <td><input type="checkbox" name="disable_subject" id="nosub" value="1"#{checked(boardopts['disable_subject'] == '1')} /><label for="nosub"></label></td>
+ </tr>
+ <tr>
+ <td class="postblock">Permitir crear hilos sin imagen</td>
+ <td><input type="checkbox" name="allow_noimage" id="noimgallow" value="1"#{checked(boardopts['allow_noimage'] == '1')} /><label for="noimgallow"></label></td>
+ </tr>
+ <tr>
+ <td class="postblock">Permitir subida</td>
+ <td><input type="checkbox" name="allow_images" id="img" value="1"#{checked(boardopts['allow_images'] == '1')} /><label for="img">Al crear un hilo</label><br /><input type="checkbox" name="allow_image_replies" id="imgres" value="1"#{checked(boardopts['allow_image_replies'] == '1')} /><label for="imgres">Al responder</label></td>
+ </tr>
+ <tr>
+ <td class="postblock">Tipos de archivo</td>
+ <td>
+ <?py for filetype in filetypes: ?>
+ <input type="checkbox" name="filetype#{filetype['ext']}" id="#{filetype['ext']}" value="1"#{checked(filetype['ext'] in supported_filetypes)} /><label for="#{filetype['ext']}">${filetype['ext'].upper()}</label><br />
+ <?py #endfor ?>
+ </td>
+ </tr>
+ <tr>
+ <td class="postblock">Tamaño máximo <span style="font-weight:normal;">(KB)</span></td>
+ <td><input type="text" name="maxsize" value="#{boardopts['maxsize']}" maxlength="5" size="11" /></td>
+ </tr>
+ <tr>
+ <td class="postblock">Dimensión de miniatura <span style="font-weight:normal;">(px)</span></td>
+ <td><input type="text" name="thumb_px" value="#{boardopts['thumb_px']}" maxlength="3" size="11" /></td>
+ </tr>
+ <tr>
+ <td class="postblock">Hilos en página frontal</td>
+ <td><input type="text" name="numthreads" value="#{boardopts['numthreads']}" maxlength="2" size="11" /></td>
+ </tr>
+ <tr>
+ <td class="postblock">Respuestas a mostrar</td>
+ <td><input type="text" name="numcont" value="#{boardopts['numcont']}" maxlength="2" size="11" /></td>
+ </tr>
+</table>
+<hr />
+<input type="submit" value="Guardar cambios" />
+</form>
+<hr />
+<a href="#{cgi_url}anarkia" class="return">Volver al menú</a>
+<?py elif mode == 2: ?>
+<div class="replymode">Denuncias</div>
+<?py if reports: ?>
+ <table class="managertable" style="max-width:1000px">
+ <tr>
+ <th>Post</th>
+ <th>Fecha</th>
+ <th style="min-width:200px;">Razón</th>
+ </tr>
+ <?py for r in reports: ?>
+ <tr>
+ <td><a href="?thread=#{r['parentid'] if r['parentid'] != "0" else r['postid']}##{r['postid']}" style="font-weight:bold">#{r['postid']}</td>
+ <td>${r['timestamp_formatted']}</td>
+ <td>#{r['reason']}</a></td>
+ </tr>
+ <?py #endfor ?>
+ </table>
+<?py else: ?>
+ No hay denuncias.<br />
+<?py #endif ?>
+<br />
+<div class="replymode">Lista de hilos</div>
+<table class="managertable" style="max-width:1000px;">
+<tr>
+ <th>#</th>
+ <th>ID</th>
+ <th style="width:50%;">Asunto</th>
+ <th>Fecha</th>
+ <th style="width:50%;">Mensaje</th>
+ <th>Posts</th>
+ <th>Acción</th>
+</tr>
+<?py i = 1 ?>
+<?py for thread in threads: ?>
+<tr>
+ <td>#{i}</td>
+ <td>#{thread['id']}</td>
+ <td><a href="?thread=#{thread['id']}" style="font-size:16pt;"><b>#{thread['subject'][:30]}</b></a></td>
+ <td>#{thread['timestamp_formatted'][:21]}</td>
+ <td>${thread['message'][:250]}</td>
+ <td>#{thread['length']}</td>
+ <td>[<a href="?lock=#{thread['id']}">#{"<b>Abrir</b>" if thread['locked'] == "1" else "Cerrar"}</a>]</td>
+</tr>
+<?py i += 1 ?>
+<?py #endfor ?>
+</table>
+<hr /><a href="#{cgi_url}anarkia" class="return">Volver al menú</a>
+<?py elif mode == 3: ?>
+<div class="replymode" style="font-size:16pt">Hilo: ${posts[0]['subject']} (#{posts[0]['length']})</div>
+<table class="managertable" style="width:1000px;">
+ <tr>
+ <th>#</th>
+ <th>ID</th>
+ <th>Fecha</th>
+ <th>Nombre</th>
+ <th>Mensaje</th>
+ <th>Usuario</th>
+ </tr>
+<?py i = 1 ?>
+<?py for p in posts: ?>
+<?py if p['IS_DELETED'] == '0': ?>
+ <tr>
+ <td>#{i}</td>
+ <td class="long">
+ <b>#{p['id']}</b>
+ <?py if p['parentid'] != '0': ?>
+ [<a href="?del=#{p['id']}">Eliminar</a>]
+ <?py else: ?>
+ [<a href="?lock=#{p['id']}">#{"<b>Abrir</b>" if p['locked'] == "1" else "Cerrar"}</a>]
+ <?py #endif ?>
+ </td>
+ <td>${p['timestamp_formatted']}</td>
+ <td><span class="postername">${p['name']}</span></td>
+ <td>${p['message']}</td>
+ <td class="long">#{p['ip'][:4]} [<a href="?ban=#{p['id']}">Ban</a>]</td>
+ </tr>
+<?py else: ?>
+ <tr>
+ <td>#{i}</td>
+ <td class="long"><b>#{p['id']}</b> [<a href="?restore=#{p['id']}">Recuperar</a>]</td>
+ <td colspan="4">Eliminado.</td>
+ </tr>
+<?py #endif ?>
+<?py i += 1 ?>
+<?py #endfor ?>
+</table>
+<hr /><a href="#{cgi_url}anarkia/mod" class="return">Volver al panel de moderación</a>
+<?py elif mode == 4: ?>
+<div class="replymode">Colocar ban</div>
+<form action="#{cgi_url}anarkia/mod" name="banform" method="post">
+<input type="hidden" name="banto" value="#{post['id']}" />
+<table>
+ <tr><td class="postblock">Ban para usuario</td><td><b>#{post['ip'][-4:]}</b></td></tr>
+ <tr><td class="postblock">Mensaje</td><td><textarea name="reason" class="full" maxlength="512"></textarea></td></tr>
+ <tr><td class="postblock">Ciego</td><td><input type="checkbox" name="blind" value="1" checked="checked" /></td></tr>
+ <tr><td class="postblock">Expira en <span style="font-weight:normal;">(segundos)</span></td>
+ <td><input type="text" name="seconds" class="full" value="3600" maxlength="8" /><br />
+ <a href="#" onclick="document.banform.seconds.value='0';return false;">Nunca</a>
+ <a href="#" onclick="document.banform.seconds.value='3600';return false;">1hr</a>
+ <a href="#" onclick="document.banform.seconds.value='43200';return false;">12hr</a>
+ <a href="#" onclick="document.banform.seconds.value='86400';return false;">1d</a>
+ <a href="#" onclick="document.banform.seconds.value='259200';return false;">3d</a>
+ <a href="#" onclick="document.banform.seconds.value='604800';return false;">1w</a>
+ <a href="#" onclick="document.banform.seconds.value='2592000';return false;">1m</a>
+ <a href="#" onclick="document.banform.seconds.value='31536000';return false;">1yr</a>
+ </td>
+ </tr>
+ <tr><td colspan="2"><input type="submit" value="Banear" class="full" /></td></tr>
+</table>
+</form>
+<hr />
+<a href="#{cgi_url}anarkia/mod" class="return">Volver al panel de moderación</a>
+<?py elif mode == 5: ?>
+<div class="replymode">Lista de bans</div>
+<table class="managertable" style="max-width:1000px;">
+<tr>
+ <th>ID</th>
+ <th>Usuario</th>
+ <th>Puesto</th>
+ <th>Expira</th>
+ <th>Ciego</th>
+ <th style="min-width:200px;">Razón</th>
+ <th>Acción</th>
+</tr>
+<?py if bans: ?>
+ <?py for ban in bans: ?>
+ <tr>
+ <td class="long">#{ban['id']}</td>
+ <td>#{ban['ip'][-4:]}</td>
+ <td>${ban['added']}</td>
+ <td>${ban['until']}</td>
+ <td>${"Sí" if ban['blind'] == "1" else "No"}</td>
+ <td>${ban['reason']}</td>
+ <td>[<a href="?unban=#{ban['id']}">Eliminar ban</a>]</td>
+ </tr>
+ <?py #endfor ?>
+<?py else: ?>
+ <tr><td colspan="7" style="text-align:center;">No hay bans.</td></tr>
+<?py #endif ?>
+</table>
+<hr />
+<a href="#{cgi_url}anarkia" class="return">Volver al menú</a>
+<?py elif mode == 6: ?>
+<div class="replymode">Editar CSS</div>
+<p><b>Editando:</b> <code>${basename}</code></p>
+<p style="font-size:small">Dominios permitidos: https://bienvenidoainternet.org https://i.imgur.com</p>
+<form action="#{cgi_url}anarkia/css" name="cssform" method="post" style="display:inline-block;">
+<textarea name="cssfile" cols="100" rows="30">${cssfile}</textarea><br />
+<input type="submit" value="Guardar cambios" class="full" />
+</form>
+<hr />
+<a href="#{cgi_url}anarkia" class="return">Volver al menú</a>
+<?py elif mode == 7: ?>
+<div class="replymode">Cambiar tipo de sección</div>
+<h1 style="color:red;font-size:26pt;text-decoration:underline;">ATENCIÓN</h1>
+<p style="font-size:19pt">Estás a punto de cambiar la estructura de esta sección a #{type_do}.</p>
+<p style="font-size:15pt">Esta sección es actualmente un #{type_now} y si prosigues transformarás su estructura a un #{type_do}.</p>
+<p style="color:red;font-size:15pt;">Nótese que este cambio se puede hacer sólo una vez cada 10 minutos.</p>
+<div style="display:inline-block;">
+ <p style="margin-top:0;">¿Seguro que deseas convertir esta sección a #{type_do}?
+ <form method="get">
+ <input type="hidden" name="transform" value="do">
+ <input type="submit" value="Transformar a #{type_do}" class="full" />
+ </form>
+ </p>
+</div>
+<hr />
+<a href="#{cgi_url}anarkia" class="return">Volver al menú</a>
+<?py elif mode == 8: ?>
+<div class="replymode">Emojis</div>
+<table class="managertable">
+ <tr><th>Nombre</th><th>Img</th></tr>
+ <?py for emoji in emojis: ?>
+ <tr><td>${emoji['from']}</td><td>#{emoji['to']}</td></tr>
+ <?py #endfor ?>
+</table>
+<hr />
+<form method="post" action="" enctype="multipart/form-data">
+<table>
+<tr>
+ <td class="postblock">Nombre</td>
+ <td><input type="text" name="name" size="15" maxlength="15" class="full" /></td>
+ <td><input type="submit" name="new" value="Agregar emoji" class="full" /></td>
+</tr>
+<tr><td class="postblock">Archivo</td><td colspan="2"><input type="file" name="file" size="15" class="full" /></td></tr>
+</table>
+<small>(Sólo letras y/o números. Máximo: 500x500px, 500 KB.)</small>
+</form>
+<hr />
+<a href="#{cgi_url}anarkia" class="return">Volver al menú</a>
+<?py elif mode == 99: ?>
+<div>${msg}<br /><br /><a href="#{cgi_url}anarkia" class="return">Volver al menú</a></div>
+<?py #endif ?>
+</center>
+<hr />
+<div class="replymode" style="font-size:26pt;color:red;font-weight:bold">ⒶⒶⒶⒶⒶⒶⒶⒶⒶⒶⒶⒶⒶⒶ</div>
+<?py include('templates/base_bottom.html') ?> \ No newline at end of file
diff --git a/cgi/templates/banned.html b/cgi/templates/banned.html
new file mode 100644
index 0000000..23b6636
--- /dev/null
+++ b/cgi/templates/banned.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+<title>Acceso prohibido@B.a.I.</title>
+<meta http-equiv="Content-Type" content="application/xhtml+xml; charset=UTF-8" />
+<style type="text/css">
+html { text-align:center; }
+body { background:#fe7777;color:#6a0000;display:inline-block;font-size:13pt;max-width:1000px;text-align:left; }
+h1 { color:red;margin:0; }
+h2 { margin:0.5em 0; }
+</style>
+</head>
+<body>
+<h1>Mensaje de Bienvenido a Internet BBS/IB</h1>
+<h2>Se te ha prohibido el acceso :-(</h2>
+<p>¡Tu IP (o rango) ha sido bloqueado!</p>
+<?py if reason != "": ?>
+ <p>La razón dejada fue: <b>#{reason}</b> y tu ban fue puesto el <b>#{added}</b> para las siguientes secciones: <b>#{boards_str}</b></p>
+<?py else: ?>
+ <p>No sabemos qué es lo que pudo causar tu ban, ¿qué hiciste?</p>
+ <p>Tu ban fue puesto el <b>#{added}</b> para las siguientes secciones: <b>#{boards_str}</b></p>
+<?py #endif ?>
+<?py if expire != "": ?>
+ <p>Pero no te preocupes, se te concederá nuevamente el acceso en la siguiente fecha y hora: <b>#{expire}</b>.</p>
+<?py #endif ?>
+<p>Si tu expulsión fue puesta incorrectamente no dudes en <a href="mailto:burocracia@bienvenidoainternet.org">contactarnos</a> dando tu IP, razón y explicación de los hechos.</p>
+<p>¡Gracias por usar Bienvenido a Internet BBS/IB!</p>
+<hr />
+<p><small><i>En muchos casos a pesar de que hayas sido expulsado del sitio se concede el acceso a las secciones <a href="/bai/">Meta</a> y Anarkía. Bajo cualquier consulta o reclamo <a href="mailto:burocracia@bienvenidoainternet.org">contáctanos</a>.</i></small></p>
+<hr />
+<div style="text-align:right;">Bienvenido a Internet 2010-2018</div>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/base_bottom.html b/cgi/templates/base_bottom.html
new file mode 100644
index 0000000..102f8f2
--- /dev/null
+++ b/cgi/templates/base_bottom.html
@@ -0,0 +1,3 @@
+<div class="footer">- <a href="//www.bienvenidoainternet.org" target="_top">weabot</a> <?py include('templates/revision.html') ?> -</div>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/base_top.html b/cgi/templates/base_top.html
new file mode 100644
index 0000000..5389617
--- /dev/null
+++ b/cgi/templates/base_top.html
@@ -0,0 +1,55 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+<?py if 'matome' in _context: ?>
+ <title>#{matome} - #{board_long}</title>
+<?py elif board: ?>
+ <title>#{board_long}</title>
+ <?py else: ?>
+ <title>#{title}</title>
+<?py #endif ?>
+ <meta http-equiv="Content-Type" content="application/xhtml+xml;charset=utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<?py if replythread and 'threads' in _context and 'preview' in _context: ?>
+ <meta property="og:site_name" content="Bienvenido a Internet IB" />
+ <meta property="twitter:site" content="Bienvenido a Internet IB" />
+ <meta name="description" content="${preview}" />
+ <meta property="og:title" content="${threads[0]['posts'][0]['subject']} - ${board_name}" />
+ <meta property="og:description" content="${preview}" />
+ <?py if threads[0]['posts'][0]['thumb']: ?>
+ <meta property="twitter:image" content="https://bienvenidoainternet.org/#{board}/thumb/#{threads[0]['posts'][0]['thumb']}" />
+ <meta property="og:image" content="https://bienvenidoainternet.org/#{board}/thumb/#{threads[0]['posts'][0]['thumb']}" />
+ <?py #endif ?>
+ <meta property="twitter:title" content="${threads[0]['posts'][0]['subject']} - ${board_name}" />
+ <meta name="twitter:description" content="${preview}" />
+<?py #endif ?>
+ <meta name="robots" content="#{"noindex" if noindex else "index, follow"}" />
+ <link rel="shortcut icon" href="/favicon.ico" />
+ <link rel="stylesheet" href="#{static_url}css/ib.css" />
+<?py if not force_css: ?>
+ <link rel="stylesheet" id="css" href="#{static_url}css/#{styles[styles_default].lower()}.css" />
+<?py else: ?>
+ <link rel="stylesheet" type="text/css" href="#{force_css}" />
+<?py #endif ?>
+<?py if board == "2d": ?>
+ <link rel="stylesheet" href="#{static_url}css/txt/sjis.css" />
+<?py #endif ?>
+ <script type="text/javascript" src="#{static_url}js/weabot.js?v=5"></script>
+ <script type="text/javascript" src="#{static_url}js/aquiencitas.js"></script>
+ <script type="text/javascript" src="#{static_url}js/autorefresh.js?v=3"></script>
+</head>
+<body#{' class="res"' if replythread else ''}>
+ <div id="main_nav">[<a href="/" target="_top">Bienvenido a Internet</a>] [<?py include('templates/navbar.html') ?>]
+ <?py if not force_css: ?>
+ <span>[<span>Apariencia:</span>
+ <?py for title in styles: ?> <a href="#" class="ss">#{title}</a><?py #endfor ?>]</span>
+ <?py #endif ?></div>
+ <div class="logo">
+ <?py if board: ?>
+ #{board_long}
+ <?py else: ?>
+ <img src="/static/img/default.png" width="500" height="81" />
+ <?py #endif ?>
+ </div>
+ <hr width="90%" size="1" />
diff --git a/cgi/templates/board.0.html b/cgi/templates/board.0.html
new file mode 100644
index 0000000..1557cbc
--- /dev/null
+++ b/cgi/templates/board.0.html
@@ -0,0 +1,230 @@
+<?py include('templates/base_top.html') ?>
+<?py if replythread or oek_finish: ?>
+ &#91;<a href="#{boards_url}#{board}/">Volver al IB</a>&#93;
+<?py #endif ?>
+<?py if replythread: ?>
+ &#91;<a href="/cgi/catalog/${board}">Catálogo</a>&#93;
+ &#91;<a href="#bottom" name="top">Bajar</a>&#93;
+ <div class="replymode">Modo Respuesta</div>
+<?py #endif ?>
+<a name="postbox"></a>
+<div class="postarea">
+<?py if allow_oekaki and not oek_finish: ?>
+ <center><form class="oekform" action="#{cgi_url}oekaki/paint" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="board" value="#{board}" />
+ <?py if replythread: ?>
+ <input type="hidden" name="parent" value="#{replythread}" />
+ <?py #endif ?>
+ Usar: <select name="oek_applet">
+ <option value="neo">PaintBBS NEO</option>
+ <option value="tegaki">Tegaki</option>
+ <option value="wpaint">wPaint</option>
+ <option value="shipainter|n|n">Shi-Painter</option>
+ <option value="shipainter|y|n">Shi-Painter Pro</option>
+ </select>
+ <span id="oek_size"><input type="text" name="oek_x" size="4" maxlength="4" value="300" /> x <input type="text" name="oek_y" size="4" maxlength="4" value="300" /></span>
+ <input type="submit" value="Dibujar" /><br /><a href="#{cgi_url}oekaki/finish/#{board}/#{replythread}">Recuperar dibujo guardado</a>
+ </form></center>
+<?py #endif ?>
+<?py if oek_finish: ?>
+<center style="margin-bottom:0.5em;"><table border=""><tr><td>
+ <?py if oek_finish == "no": ?>
+ <font size="+3">No hay dibujo</font>
+ <?py else: ?>
+ <img src="#{boards_url}oek_temp/#{oek_finish}.png?ts=#{ts}" />
+ <?py #endif ?>
+</td></tr></table></center>
+<?py #endif ?>
+<form name="postform" id="postform" action="#{cgi_url}post" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="board" value="#{board}" />
+ <?py if replythread: ?>
+ <input type="hidden" name="parent" value="#{replythread}" />
+ <input type="hidden" name="default_subject" value="#{default_subject}" />
+ <?py #endif ?>
+ <div style="display:none;"><input type="text" name="name" size="25" /> <input type="text" name="email" size="25" /></div>
+ <table class="postform">
+ <tr>
+ <td class="postblock">mediumo</td>
+ <td>
+ <input type="text" name="fieldb" size="25" accesskey="e" />
+ <?py if disable_subject: ?>
+ <?py if replythread: ?>
+ <input type="submit" value="🤡" accesskey="z" />
+ <?py else: ?>
+ <input type="submit" value="🤡" accesskey="z" />
+ <?py #endif ?>
+ <?py #endif ?>
+ </td>
+ </tr>
+ <?py if not disable_subject: ?>
+ <tr>
+ <td class="postblock">Asunto</td>
+ <td>
+ <input type="text" name="subject" size="35" maxlength="100" accesskey="s" />
+ <?py if replythread: ?>
+ <input type="submit" value="Responder" accesskey="z" />
+ <?py else: ?>
+ <input type="submit" value="Crear hilo" accesskey="z" />
+ <?py #endif ?>
+ </td>
+ </tr>
+ <?py #endif ?>
+ <tr>
+ <td class="postblock">molekuloj</td>
+ <td><textarea name="message" cols="50" rows="6" accesskey="m"></textarea></td>
+ </tr>
+ <?py if (replythread and allow_image_replies) or (not replythread and allow_images): ?>
+ <tr>
+ <td class="postblock">amiko</td>
+ <td>
+ <input type="file" name="file" id="file" accesskey="f" />
+ <span id="filepreview" style="display:none;"></span>
+ <?py if allow_spoilers: ?>
+ <label>[<input type="checkbox" name="spoil" id="spoil" />Spoiler]</label>
+ <?py #endif ?>
+ <?py if allow_noimage and not replythread: ?>
+ <label>[<input type="checkbox" name="noimage" id="noimage" />Sin imagen]</label>
+ <?py #endif ?>
+ </td>
+ </tr>
+ <?py #endif ?>
+ <tr class="pass">
+ <td class="postblock">timo</td>
+ <td><input type="password" name="password" size="8" accesskey="p" /> (uzata por post forigo)</td>
+ </tr>
+ <tr>
+ <td colspan="2" class="rules">
+ <ul>
+ #{postarea_desc}
+ <li>ni ne vivas timi, ni vivas konekti.</li>
+ <?py if supported_filetypes: ?>
+ <li>elekti la veneno: <span id="filetypes">#{', '.join(supported_filetypes).upper()}</span>. ĝis: <span id="maxsize">#{maxsize}</span>KB. paŝo: #{maxdimensions}x#{maxdimensions}px</li>
+ <?py #endif ?>
+ </ul>
+ </td>
+ </tr>
+ </table>
+</form>
+</div>
+<hr />
+<?py if postarea_extra: ?>
+<center>#{postarea_extra}</center>
+<hr />
+<?py #endif ?>
+<form id="delform" action="#{cgi_url}delete" method="post">
+ <input type="hidden" name="board" value="#{board}" />
+ <?py if threads: ?>
+ <?py for thread in threads: ?>
+ <?py if not replythread: ?>
+ <span id="unhide#{thread['id']}#{board}" style="display:none;">Hilo <a href="#{boards_url}#{board}/res/#{thread['id']}.html">#{thread['id']}</a> oculto. <a class="tt" href="#">Ver hilo</a></span>
+ <?py #endif ?>
+ <div id="thread#{thread['id']}#{board}" class="thread" data-length="#{thread['length']}">
+ <?py for post in thread['posts']: ?>
+ <?py if int(post['parentid']) != 0: ?>
+ <table><tr><td class="ell">…</td>
+ <td class="reply" id="reply#{post['id']}">
+ <?py elif post['file']: ?>
+ <?py if post['image_width'] != '0': ?>
+ <div class="fs"><span>Nombre de archivo:</span><a href="#{images_url}#{board}/src/#{post['file']}" class="expimg" target="_blank" data-id="#{post['id']}" data-thumb="#{images_url}#{board}/thumb/#{post['thumb']}" data-w="#{post['image_width']}" data-h="#{post['image_height']}" data-tw="#{post['thumb_width']}" data-th="#{post['thumb_height']}">#{post['file']}</a>-(#{post['file_size']} B, #{post['image_width']}x#{post['image_height']})
+ <?py else: ?>
+ <div class="fs"><span>Nombre de archivo:</span><a href="#{images_url}#{board}/src/#{post['file']}" target="_blank">#{post['file']}</a>-(#{post['file_size']} B)
+ <?py #endif ?>
+ <?py if not replythread: ?>
+ [<a href="#" title="Ocultar hilo" class="tt">Ocultar hilo</a>]
+ <?py #endif ?>
+ </div>
+ <a target="_blank" href="#{images_url}#{board}/src/#{post['file']}" id="thumb#{post['id']}">
+ <?py if post['thumb'].startswith('mime'): ?>
+ <img class="thumb" alt="#{post['id']}" src="/static/#{post['thumb']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" />
+ <?py elif post['file'][-3:] == 'gif': ?>
+ <img class="thumb" alt="#{post['id']}" src="#{images_url}#{board}/src/#{post['file']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" />
+ <?py else: ?>
+ <img class="thumb" alt="#{post['id']}" src="#{images_url}#{board}/thumb/#{post['thumb']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" />
+ <?py #endif ?>
+ </a>
+ <?py #endif ?>
+ <a name="#{post['id']}"></a>
+ <?py if post['IS_DELETED'] == '1': ?>
+ <span class="deleted">No.#{post['id']} eliminado por usuario.</span>
+ <?py elif post['IS_DELETED'] == '2': ?>
+ <span class="deleted">No.#{post['id']} eliminado por miembro del staff.</span>
+ <?py else: ?>
+ <div class="info"><label><input type="checkbox" name="delete" value="#{post['id']}" />
+ <?py if post['subject'] : ?>
+ <span class="subj">#{post['subject']}</span>
+ <?py #endif ?></label>
+ <?py if post['tripcode']: ?>
+ <span class="name"><b>#{post['name']}</b> #{post['tripcode']}</span>
+ <?py else: ?>
+ <span class="name"><b>#{post['name']}</b></span>
+ <?py #endif ?>
+ <span class="date" data-unix="#{random.randint(1,2147483647)}">#{post['timestamp_formatted']}</span>
+ <span class="reflink"><a>No.#{random.randint(1,999999)}</a></span>
+ <a class="rep" href="#{cgi_url}report/#{board}/#{post['id']}" rel="nofollow">rep</a>
+ <?py if int(post['parentid']) != 0: ?>
+ <?py if post['file']: ?>
+ <div class="fs">
+ <?py if post['image_width'] != '0': ?>
+ <a href="#{images_url}#{board}/src/#{post['file']}" class="expimg" target="_blank" data-id="#{post['id']}" data-thumb="#{images_url}#{board}/thumb/#{post['thumb']}" data-w="#{post['image_width']}" data-h="#{post['image_height']}" data-tw="#{post['thumb_width']}" data-th="#{post['thumb_height']}">#{post['file']}</a>-(#{post['file_size']} B, #{post['image_width']}x#{post['image_height']})
+ <?py else: ?>
+ <a href="#{images_url}#{board}/src/#{post['file']}" target="_blank">#{post['file']}</a>-(#{post['file_size']} B)
+ <?py #endif ?>
+ </div>
+ <a target="_blank" href="#{images_url}#{board}/src/#{post['file']}" id="thumb#{post['id']}">
+ <?py if post['thumb'].startswith('mime'): ?>
+ <img class="thumb" alt="#{post['id']}" src="/static/#{post['thumb']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" />
+ <?py elif post['file'][-3:] == 'gif': ?>
+ <img class="thumb" alt="#{post['id']}" src="#{images_url}#{board}/src/#{post['file']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" />
+ <?py else: ?>
+ <img class="thumb" alt="#{post['id']}" src="#{images_url}#{board}/thumb/#{post['thumb']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" />
+ <?py #endif ?>
+ </a>
+ <?py #endif ?>
+ <?py #endif ?>
+ <?py if int(post['parentid']) == 0 and not replythread: ?>
+ [<a href="#{boards_url}#{board}/res/#{post['id']}.html" class="hsbn">Responder</a>]
+ <?py if post['file'] == '': ?>
+ [<a href="#" title="Ocultar Hilo" class="tt">Ocultar</a>]
+ <?py #endif ?>
+ <?py #endif ?>
+ </div>
+ <?py if post['thumb_width'] != '0' and post['parentid'] != '0': ?>
+ <blockquote style="margin-left:#{int(post['thumb_width'])+40}px;">
+ <?py else: ?>
+ <blockquote>
+ <?py #endif ?>
+ #{post['message']}
+ </blockquote>
+ <?py if not replythread and post['shortened']: ?>
+ <blockquote class="abbrev">(Post muy largo... Presiona <a href="#{boards_url}#{board}/res/#{post['id'] if post['parentid'] == "0" else post['parentid']}.html##{post['id']}">aqu&iacute;</a> para verlo completo.)</blockquote>
+ <?py #endif ?>
+ <?py #endif ?>
+ <?py if post['parentid'] == "0": ?>
+ <?py if not replythread: ?>
+ <?py if int(thread['omitted']) == 1: ?>
+ <div class="omitted">Un post omitido. Haz clic en Responder para ver.</div>
+ <?py elif int(thread['omitted']) > 1: ?>
+ <div class="omitted">#{thread['omitted']} posts omitidos. Haz clic en Responder para ver.</div>
+ <?py #endif ?>
+ <?py #endif ?>
+ <?py else: ?>
+ </td></tr></table>
+ <?py #endif ?>
+ <?py #endfor ?>
+ <div class="cut"></div></div>
+ <?py if replythread: ?>
+ <hr />
+ <div class="nav">&#91;<a href="#{boards_url}#{board}/">Volver al IB</a>&#93;
+ &#91;<a href="/cgi/catalog/${board}">Catálogo</a>&#93;
+ &#91;<a href="#top" name="bottom">Subir</a>&#93;</div>
+ <?py #endif ?>
+ <hr />
+ <?py #endfor ?>
+ <div class="userdel">Eliminar post <label>[<input type="checkbox" name="imageonly" id="imageonly" />Sólo imagen]</label><br />
+ Clave <input type="password" name="password" size="8" /> <input name="deletepost" value="Eliminar" type="submit" /></div>
+ <?py #endif ?>
+</form>
+<?py if pagenav: ?>
+ <div class="pg">#{pagenav}</div>
+<?py #endif ?>
+<?py include('templates/base_bottom.html') ?> \ No newline at end of file
diff --git a/cgi/templates/board.html b/cgi/templates/board.html
new file mode 100644
index 0000000..e91e187
--- /dev/null
+++ b/cgi/templates/board.html
@@ -0,0 +1,264 @@
+<?py include('templates/base_top.html') ?>
+<?py if replythread or oek_finish: ?>
+ &#91;<a href="#{boards_url}#{board}/">Volver al IB</a>&#93;
+<?py #endif ?>
+<?py if replythread: ?>
+ &#91;<a href="/cgi/catalog/${board}">Catálogo</a>&#93;
+ &#91;<a href="#bottom" name="top">Bajar</a>&#93;
+ <div class="replymode">Modo Respuesta</div>
+<?py #endif ?>
+<a name="postbox"></a>
+<div class="postarea">
+<?py if allow_oekaki and not oek_finish: ?>
+ <center><form class="oekform" action="#{cgi_url}oekaki/paint" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="board" value="#{board}" />
+ <?py if replythread: ?>
+ <input type="hidden" name="parent" value="#{replythread}" />
+ <?py #endif ?>
+ Usar: <select name="oek_applet">
+ <option value="neo">PaintBBS NEO</option>
+ <option value="tegaki">Tegaki</option>
+ <option value="wpaint">wPaint</option>
+ <option value="shipainter|n|n">Shi-Painter</option>
+ <option value="shipainter|y|n">Shi-Painter Pro</option>
+ </select>
+ <span id="oek_size"><input type="text" name="oek_x" size="4" maxlength="4" value="300" /> x <input type="text" name="oek_y" size="4" maxlength="4" value="300" /></span>
+ <input type="submit" value="Dibujar" /><br /><a href="#{cgi_url}oekaki/finish/#{board}/#{replythread}">Recuperar dibujo guardado</a>
+ </form></center>
+<?py #endif ?>
+<?py if oek_finish: ?>
+<center style="margin-bottom:0.5em;"><table border=""><tr><td>
+ <?py if oek_finish == "no": ?>
+ <font size="+3">No hay dibujo</font>
+ <?py else: ?>
+ <img src="#{boards_url}oek_temp/#{oek_finish}.png?ts=#{ts}" />
+ <?py #endif ?>
+</td></tr></table></center>
+<?py #endif ?>
+<form name="postform" id="postform" action="#{cgi_url}post" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="board" value="#{board}" />
+ <?py if replythread: ?>
+ <input type="hidden" name="parent" value="#{replythread}" />
+ <input type="hidden" name="default_subject" value="#{default_subject}" />
+ <?py #endif ?>
+ <div style="display:none;">Trampa: <input type="text" name="name" size="25" /> <input type="text" name="email" size="25" /></div>
+ <table class="postform">
+ <?py if not disable_name: ?>
+ <tr>
+ <td class="postblock">Nombre</td>
+ <td><input type="text" name="fielda" size="25" accesskey="n" /></td>
+ </tr>
+ <?py #endif ?>
+ <tr>
+ <td class="postblock">E-mail</td>
+ <td>
+ <input type="text" name="fieldb" size="25" accesskey="e" />
+ <?py if disable_subject: ?>
+ <?py if replythread: ?>
+ <input type="submit" value="Responder" accesskey="z" />
+ <?py else: ?>
+ <input type="submit" value="Crear hilo" accesskey="z" />
+ <?py #endif ?>
+ <?py #endif ?>
+ </td>
+ </tr>
+ <?py if not disable_subject: ?>
+ <tr>
+ <td class="postblock">Asunto</td>
+ <td>
+ <input type="text" name="subject" size="35" maxlength="100" accesskey="s" />
+ <?py if replythread: ?>
+ <input type="submit" value="Responder" accesskey="z" />
+ <?py else: ?>
+ <input type="submit" value="Crear hilo" accesskey="z" />
+ <?py #endif ?>
+ </td>
+ </tr>
+ <?py #endif ?>
+ <tr>
+ <td class="postblock">Mensaje</td>
+ <td><textarea name="message" cols="50" rows="6" accesskey="m"></textarea></td>
+ </tr>
+ <?py if not oek_finish: ?>
+ <?py if (replythread and allow_image_replies) or (not replythread and allow_images): ?>
+ <tr>
+ <td class="postblock">Archivo</td>
+ <td>
+ <input type="file" name="file" id="file" accesskey="f" />
+ <span id="filepreview" style="display:none;"></span>
+ <?py if allow_spoilers: ?>
+ <label>[<input type="checkbox" name="spoil" id="spoil" />Spoiler]</label>
+ <?py #endif ?>
+ <?py if allow_noimage and not replythread: ?>
+ <label>[<input type="checkbox" name="noimage" id="noimage" />Sin imagen]</label>
+ <?py #endif ?>
+ </td>
+ </tr>
+ <?py #endif ?>
+ <?py elif oek_finish != 'no': ?>
+ <input type="hidden" name="oek_file" value="#{oek_finish}" />
+ <?py #endif ?>
+ <tr class="pass">
+ <td class="postblock">Clave</td>
+ <td><input type="password" name="password" size="8" accesskey="p" /> (para eliminar el post)</td>
+ </tr>
+ <tr>
+ <td colspan="2" class="rules">
+ <ul>
+ #{postarea_desc}
+ <?py if supported_filetypes: ?>
+ <li>Archivos permitidos: <span id="filetypes">#{', '.join(supported_filetypes).upper()}</span>. Hasta <span id="maxsize">#{maxsize}</span>KB. Miniaturas: #{maxdimensions}x#{maxdimensions}px</li>
+ <?py #endif ?>
+ <?py if not replythread: ?>
+ <li><a href="/cgi/catalog/${board}">Catálogo de hilos</a> (Orden: <a href="/cgi/catalog/${board}?sort=1">Nuevo</a>/<a href="/cgi/catalog/${board}?sort=2">Viejo</a>/<a href="/cgi/catalog/${board}?sort=3">Más</a>/<a href="/cgi/catalog/${board}?sort=4">Menos</a>)</li>
+ <?py #endif ?>
+ <?py if int(maxage) != 0: ?>
+ <li>Los hilos son automáticamente eliminados a los <b>#{maxage}</b> días de edad.</li>
+ <?py #endif ?>
+ <li>¿Eres nuevo? <a href="/guia.html">Leer antes de postear</a> · <a href="/faq.html">Preguntas frecuentes</a> · <a href="/bai/">Contacto</a></li>
+ </ul>
+ </td>
+ </tr>
+ </table>
+</form>
+</div>
+<hr />
+<?py if postarea_extra: ?>
+<center>#{postarea_extra}</center>
+<hr />
+<?py #endif ?>
+<form id="delform" action="#{cgi_url}delete" method="post">
+ <input type="hidden" name="board" value="#{board}" />
+ <?py if threads: ?>
+ <?py for thread in threads: ?>
+ <?py if not replythread: ?>
+ <span id="unhide#{thread['id']}#{board}" style="display:none;">Hilo <a href="#{boards_url}#{board}/res/#{thread['id']}.html">#{thread['id']}</a> oculto. <a class="tt" href="#">Ver hilo</a></span>
+ <?py #endif ?>
+ <div id="thread#{thread['id']}#{board}" class="thread" data-length="#{thread['length']}">
+ <?py for post in thread['posts']: ?>
+ <?py if int(post['parentid']) != 0: ?>
+ <table><tr><td class="ell">…</td>
+ <td class="reply" id="reply#{post['id']}">
+ <?py elif post['file']: ?>
+ <?py if post['image_width'] != '0': ?>
+ <div class="fs"><span>Nombre de archivo:</span><a href="#{images_url}#{board}/src/#{post['file']}" class="expimg" target="_blank" data-id="#{post['id']}" data-thumb="#{images_url}#{board}/thumb/#{post['thumb']}" data-w="#{post['image_width']}" data-h="#{post['image_height']}" data-tw="#{post['thumb_width']}" data-th="#{post['thumb_height']}">#{post['file']}</a>-(#{post['file_size']} B, #{post['image_width']}x#{post['image_height']})
+ <?py else: ?>
+ <div class="fs"><span>Nombre de archivo:</span><a href="#{images_url}#{board}/src/#{post['file']}" target="_blank">#{post['file']}</a>-(#{post['file_size']} B)
+ <?py #endif ?>
+ <?py if not replythread: ?>
+ [<a href="#" title="Ocultar hilo" class="tt">Ocultar hilo</a>]
+ <?py #endif ?>
+ </div>
+ <a target="_blank" href="#{images_url}#{board}/src/#{post['file']}" id="thumb#{post['id']}">
+ <?py if post['thumb'].startswith('mime'): ?>
+ <img class="thumb" alt="#{post['id']}" src="/static/#{post['thumb']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" />
+ <?py elif post['file'][-3:] == 'gif': ?>
+ <img class="thumb" alt="#{post['id']}" src="#{images_url}#{board}/src/#{post['file']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" />
+ <?py else: ?>
+ <img class="thumb" alt="#{post['id']}" src="#{images_url}#{board}/thumb/#{post['thumb']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" />
+ <?py #endif ?>
+ </a>
+ <?py #endif ?>
+ <a name="#{post['id']}"></a>
+ <?py if post['IS_DELETED'] == '1': ?>
+ <span class="deleted">No.#{post['id']} eliminado por usuario.</span>
+ <?py elif post['IS_DELETED'] == '2': ?>
+ <span class="deleted">No.#{post['id']} eliminado por miembro del staff.</span>
+ <?py else: ?>
+ <div class="info"><label><input type="checkbox" name="delete" value="#{post['id']}" />
+ <?py if post['subject'] : ?>
+ <span class="subj">#{post['subject']}</span>
+ <?py #endif ?></label>
+ <?py if post['email']: ?>
+ <?py if post['tripcode']: ?>
+ <a href="mailto:#{post['email']}"><span class="name"><b>#{post['name']}</b> #{post['tripcode']}</span></a>
+ <?py else: ?>
+ <a href="mailto:#{post['email']}"><span class="name"><b>#{post['name']}</b></span></a>
+ <?py #endif ?>
+ <?py else: ?>
+ <?py if post['tripcode']: ?>
+ <span class="name"><b>#{post['name']}</b> #{post['tripcode']}</span>
+ <?py else: ?>
+ <span class="name"><b>#{post['name']}</b></span>
+ <?py #endif ?>
+ <?py #endif ?>
+ <span class="date" data-unix="#{post['timestamp']}">#{post['timestamp_formatted']}</span>
+ <?py if replythread: ?>
+ <span class="reflink"><a href="##{post['id']}">No.</a><a href="#" class="postid">#{post['id']}</a></span>
+ <?py else: ?>
+ <span class="reflink"><a href="#{boards_url}#{board}/res/#{post['parentid'] if post['parentid'] != "0" else post['id']}.html##{post['id']}">No.</a><a href="#{boards_url}#{board}/res/#{post['parentid'] if post['parentid'] != "0" else post['id']}.html#i#{post['id']}">#{post['id']}</a></span>
+ <?py #endif ?>
+ <a class="rep" href="#{cgi_url}report/#{board}/#{post['id']}" rel="nofollow">rep</a>
+ <?py if int(post['expires']): ?>
+ <small>Expira el ${post['expires_formatted']}</small>
+ <?py #endif ?>
+ <?py if int(post['parentid']) != 0: ?>
+ <?py if post['file']: ?>
+ <div class="fs">
+ <?py if post['image_width'] != '0': ?>
+ <a href="#{images_url}#{board}/src/#{post['file']}" class="expimg" target="_blank" data-id="#{post['id']}" data-thumb="#{images_url}#{board}/thumb/#{post['thumb']}" data-w="#{post['image_width']}" data-h="#{post['image_height']}" data-tw="#{post['thumb_width']}" data-th="#{post['thumb_height']}">#{post['file']}</a>-(#{post['file_size']} B, #{post['image_width']}x#{post['image_height']})
+ <?py else: ?>
+ <a href="#{images_url}#{board}/src/#{post['file']}" target="_blank">#{post['file']}</a>-(#{post['file_size']} B)
+ <?py #endif ?>
+ </div>
+ <a target="_blank" href="#{images_url}#{board}/src/#{post['file']}" id="thumb#{post['id']}">
+ <?py if post['thumb'].startswith('mime'): ?>
+ <img class="thumb" alt="#{post['id']}" src="/static/#{post['thumb']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" />
+ <?py elif post['file'][-3:] == 'gif': ?>
+ <img class="thumb" alt="#{post['id']}" src="#{images_url}#{board}/src/#{post['file']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" />
+ <?py else: ?>
+ <img class="thumb" alt="#{post['id']}" src="#{images_url}#{board}/thumb/#{post['thumb']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" />
+ <?py #endif ?>
+ </a>
+ <?py #endif ?>
+ <?py #endif ?>
+ <?py if int(post['parentid']) == 0 and not replythread: ?>
+ [<a href="#{boards_url}#{board}/res/#{post['id']}.html" class="hsbn">Responder</a>]
+ <?py if post['file'] == '': ?>
+ [<a href="#" title="Ocultar Hilo" class="tt">Ocultar</a>]
+ <?py #endif ?>
+ <?py #endif ?>
+ </div>
+ <?py if post['thumb_width'] != '0' and post['parentid'] != '0': ?>
+ <blockquote style="margin-left:#{int(post['thumb_width'])+40}px;">
+ <?py else: ?>
+ <blockquote>
+ <?py #endif ?>
+ #{post['message']}
+ </blockquote>
+ <?py if not replythread and post['shortened']: ?>
+ <blockquote class="abbrev">(Post muy largo... Presiona <a href="#{boards_url}#{board}/res/#{post['id'] if post['parentid'] == "0" else post['parentid']}.html##{post['id']}">aqu&iacute;</a> para verlo completo.)</blockquote>
+ <?py #endif ?>
+ <?py if int(post['expires_alert']): ?>
+ <div style="color:red;font-weight:bold;">Este hilo es viejo y desaparecerá pronto.</div>
+ <?py #endif ?>
+ <?py #endif ?>
+ <?py if post['parentid'] == "0": ?>
+ <?py if not replythread: ?>
+ <?py if int(thread['omitted']) == 1: ?>
+ <div class="omitted">Un post omitido. Haz clic en Responder para ver.</div>
+ <?py elif int(thread['omitted']) > 1: ?>
+ <div class="omitted">#{thread['omitted']} posts omitidos. Haz clic en Responder para ver.</div>
+ <?py #endif ?>
+ <?py #endif ?>
+ <?py else: ?>
+ </td></tr></table>
+ <?py #endif ?>
+ <?py #endfor ?>
+ <div class="cut"></div></div>
+ <?py if replythread: ?>
+ <hr />
+ <div class="nav">&#91;<a href="#{boards_url}#{board}/">Volver al IB</a>&#93;
+ &#91;<a href="/cgi/catalog/${board}">Catálogo</a>&#93;
+ &#91;<a href="#top" name="bottom">Subir</a>&#93;</div>
+ <?py #endif ?>
+ <hr />
+ <?py #endfor ?>
+ <div class="userdel">Eliminar post <label>[<input type="checkbox" name="imageonly" id="imageonly" />Sólo imagen]</label><br />
+ Clave <input type="password" name="password" size="8" /> <input name="deletepost" value="Eliminar" type="submit" /></div>
+ <?py #endif ?>
+</form>
+<?py if pagenav: ?>
+ <div class="pg">#{pagenav}</div>
+<?py #endif ?>
+<?py include('templates/base_bottom.html') ?> \ No newline at end of file
diff --git a/cgi/templates/board.jp.html b/cgi/templates/board.jp.html
new file mode 100644
index 0000000..8045ab1
--- /dev/null
+++ b/cgi/templates/board.jp.html
@@ -0,0 +1,271 @@
+<?py include('templates/base_top.html') ?>
+<?py if replythread or oek_finish: ?>
+ &#91;<a href="#{boards_url}#{board}/">掲示板に戻る</a>&#93;
+<?py #endif ?>
+<?py if replythread: ?>
+ &#91;<a href="/cgi/catalog/${board}">カタログ</a>&#93;
+ &#91;<a href="#bottom" name="top">ボトムへ行く</a>&#93;
+ <div class="replymode">レス送信モード</div>
+<?py #endif ?>
+<a name="postbox"></a>
+<div class="postarea">
+<?py if allow_oekaki and not oek_finish: ?>
+ <center><form class="oekform" action="#{cgi_url}oekaki/paint" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="board" value="#{board}" />
+ <?py if replythread: ?>
+ <input type="hidden" name="parent" value="#{replythread}" />
+ <?py #endif ?>
+ <select name="oek_applet">
+ <option value="neo">PaintBBS NEO</option>
+ <option value="tegaki">Tegaki</option>
+ <option value="wpaint">wPaint</option>
+ <option value="shipainter|n|n">Shi-Painter</option>
+ <option value="shipainter|y|n">Shi-Painter Pro</option>
+ </select>
+ <span id="oek_size"><input type="text" name="oek_x" size="4" maxlength="4" value="300" /> x <input type="text" name="oek_y" size="4" maxlength="4" value="300" /></span>
+ <input type="submit" value="お絵かきする" /><br /><a href="#{cgi_url}oekaki/finish/#{board}/#{replythread}">アップロード途中の画像</a>
+ </form></center>
+<?py #endif ?>
+<?py if oek_finish: ?>
+<center style="margin-bottom:0.5em;"><table border=""><tr><td>
+ <?py if oek_finish == "no": ?>
+ <font size="+3">画像が見当たりません</font>
+ <?py else: ?>
+ <img src="#{boards_url}oek_temp/#{oek_finish}.png?ts=#{ts}" />
+ <?py #endif ?>
+</td></tr></table></center>
+<?py #endif ?>
+<form name="postform" id="postform" action="#{cgi_url}post" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="board" value="#{board}" />
+ <?py if replythread: ?>
+ <input type="hidden" name="parent" value="#{replythread}" />
+ <input type="hidden" name="default_subject" value="#{default_subject}" />
+ <?py #endif ?>
+ <div style="display:none;">Trampa: <input type="text" name="name" size="25" /> <input type="text" name="email" size="25" /></div>
+ <table class="postform">
+ <?py if not disable_name: ?>
+ <tr>
+ <td class="postblock">おなまえ</td>
+ <td><input type="text" name="fielda" size="25" accesskey="n" /></td>
+ </tr>
+ <?py #endif ?>
+ <tr>
+ <td class="postblock">E-mail</td>
+ <td>
+ <input type="text" name="fieldb" size="25" accesskey="e" />
+ <?py if disable_subject: ?>
+ <?py if replythread: ?>
+ <input type="submit" value="返信" accesskey="z" />
+ <?py else: ?>
+ <input type="submit" value="スレッドを立てる" accesskey="z" />
+ <?py #endif ?>
+ <?py #endif ?>
+ </td>
+ </tr>
+ <?py if not disable_subject: ?>
+ <tr>
+ <td class="postblock">題  名</td>
+ <td>
+ <input type="text" name="subject" size="35" maxlength="100" accesskey="s" />
+ <?py if replythread: ?>
+ <input type="submit" value="返信" accesskey="z" />
+ <?py else: ?>
+ <input type="submit" value="スレッドを立てる" accesskey="z" />
+ <?py #endif ?>
+ </td>
+ </tr>
+ <?py #endif ?>
+ <tr>
+ <td class="postblock">コメント</td>
+ <td><textarea name="message" cols="50" rows="6" accesskey="m"></textarea></td>
+ </tr>
+ <?py if not oek_finish: ?>
+ <?py if (replythread and allow_image_replies) or (not replythread and allow_images): ?>
+ <tr>
+ <td class="postblock">添付File</td>
+ <td>
+ <input type="file" name="file" id="file" accesskey="f" />
+ <span id="filepreview" style="display:none;"></span>
+ <?py if allow_spoilers: ?>
+ <label>[<input type="checkbox" name="spoil" id="spoil" />ネタバレ]</label>
+ <?py #endif ?>
+ <?py if allow_noimage and not replythread: ?>
+ <label>[<input type="checkbox" name="noimage" id="noimage" />画像なし]</label>
+ <?py #endif ?>
+ </td>
+ </tr>
+ <?py #endif ?>
+ <?py elif oek_finish != 'no': ?>
+ <input type="hidden" name="oek_file" value="#{oek_finish}" />
+ <?py #endif ?>
+ <tr class="pass">
+ <td class="postblock">削除キー</td>
+ <td><input type="password" name="password" size="8" accesskey="p" /> (削除用)</td>
+ </tr>
+ <tr>
+ <td colspan="2" class="rules">
+ <ul>
+ #{postarea_desc}
+ <?py if supported_filetypes: ?>
+ <li>添付可能:<span id="filetypes">#{', '.join(supported_filetypes).upper()}</span>. <span id="maxsize">#{maxsize}</span>KBまで. #{maxdimensions}x#{maxdimensions}以上は縮小.</li>
+ <?py #endif ?>
+ <?py if not replythread: ?>
+ <li><a href="#{cgi_url}catalog/${board}">カタログ</a> (ソート:<a href="/cgi/catalog/${board}?sort=1">新順</a>/<a href="/cgi/catalog/${board}?sort=2">古順</a>/<a href="/cgi/catalog/${board}?sort=3">多順</a>/<a href="/cgi/catalog/${board}?sort=4">少順</a>)</li>
+ <?py #endif ?>
+ <?py if int(maxage) != 0: ?>
+ <li>スレは<b>#{maxage}</b>日間経つと自動的に消されられます.</li>
+ <?py #endif ?>
+ <li><a href="/guia.html">使い方</a> · <a href="/faq.html">よくある質問</a> · <a href="/bai/">管理人への連絡</a></li>
+ </ul>
+ </td>
+ </tr>
+ </table>
+</form>
+</div>
+<hr />
+<?py if postarea_extra: ?>
+<center>#{postarea_extra}</center>
+<hr />
+<?py #endif ?>
+<form id="delform" action="#{cgi_url}delete" method="post">
+ <input type="hidden" name="board" value="#{board}" />
+ <?py if threads: ?>
+ <?py for thread in threads: ?>
+ <?py if not replythread: ?>
+ <span id="unhide#{thread['id']}#{board}" style="display:none">スレ<a href="#{boards_url}#{board}/res/#{thread['id']}.html">#{thread['id']}</a>は隠しました. <a class="tt" href="#">スレを表示</a></span>
+ <?py #endif ?>
+ <div id="thread#{thread['id']}#{board}" class="thread" data-length="#{thread['length']}">
+ <?py for post in thread['posts']: ?>
+ <?py if int(post['parentid']) != 0: ?>
+ <table><tr><td class="ell">…</td>
+ <td class="reply" id="reply#{post['id']}">
+ <?py elif post['file']: ?>
+ <?py if post['image_width'] != '0': ?>
+ <div class="fs"><span>画像ファイル名:</span><a href="#{images_url}#{board}/src/#{post['file']}" class="expimg" target="_blank" data-id="#{post['id']}" data-thumb="#{images_url}#{board}/thumb/#{post['thumb']}" data-w="#{post['image_width']}" data-h="#{post['image_height']}" data-tw="#{post['thumb_width']}" data-th="#{post['thumb_height']}">#{post['file']}</a>-(#{post['file_size']} B, #{post['image_width']}x#{post['image_height']})
+ <?py else: ?>
+ <div class="fs"><span>画像ファイル名:</span><a href="#{images_url}#{board}/src/#{post['file']}" target="_blank">#{post['file']}</a>-(#{post['file_size']} B)
+ <?py #endif ?>
+ <?py if post['file'][-3:] == 'gif': ?>
+ <small>アニメGIF</small>
+ <?py elif not post['thumb'].startswith('mime'): ?>
+ <small>サムネ表示</small>
+ <?py #endif ?>
+ <?py if not replythread: ?>
+ [<a href="#" title="スレを隠す" class="tt">隠す</a>]
+ <?py #endif ?>
+ </div>
+ <a target="_blank" href="#{images_url}#{board}/src/#{post['file']}" id="thumb#{post['id']}">
+ <?py if post['thumb'].startswith('mime'): ?>
+ <img class="thumb" alt="#{post['id']}" src="/static/#{post['thumb']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" />
+ <?py elif post['file'][-3:] == 'gif': ?>
+ <img class="thumb" alt="#{post['id']}" src="#{images_url}#{board}/src/#{post['file']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" />
+ <?py else: ?>
+ <img class="thumb" alt="#{post['id']}" src="#{images_url}#{board}/thumb/#{post['thumb']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" />
+ <?py #endif ?>
+ </a>
+ <?py #endif ?>
+ <a name="#{post['id']}"></a>
+ <?py if post['IS_DELETED'] == '1': ?>
+ <span class="deleted">No.#{post['id']}はユーザーに削除されました.</span>
+ <?py elif post['IS_DELETED'] == '2': ?>
+ <span class="deleted">No.#{post['id']}は管理人に削除されました.</span>
+ <?py else: ?>
+ <div class="info"><label><input type="checkbox" name="delete" value="#{post['id']}" /><span class="subj">#{post['subject'] if post['subject'] else default_subject}</span></label>
+ <?py if post['email']: ?>
+ <?py if post['tripcode']: ?>
+ Name <a href="mailto:#{post['email']}"><span class="name"><b>#{post['name']}</b> #{post['tripcode']}</span></a>
+ <?py else: ?>
+ Name <a href="mailto:#{post['email']}"><span class="name"><b>#{post['name']}</b></span></a>
+ <?py #endif ?>
+ <?py else: ?>
+ <?py if post['tripcode']: ?>
+ Name <span class="name"><b>#{post['name']}</b> #{post['tripcode']}</span>
+ <?py else: ?>
+ Name <span class="name"><b>#{post['name']}</b></span>
+ <?py #endif ?>
+ <?py #endif ?>
+ <span class="date" data-unix="#{post['timestamp']}">#{post['timestamp_formatted']}</span>
+ <?py if replythread: ?>
+ <span class="reflink"><a href="##{post['id']}">No.</a><a href="#" class="postid">#{post['id']}</a></span>
+ <?py else: ?>
+ <span class="reflink"><a href="#{boards_url}#{board}/res/#{post['parentid'] if post['parentid'] != "0" else post['id']}.html##{post['id']}">No.</a><a href="#{boards_url}#{board}/res/#{post['parentid'] if post['parentid'] != "0" else post['id']}.html#i#{post['id']}">#{post['id']}</a></span>
+ <?py #endif ?>
+ <a class="rep" href="#{cgi_url}report/#{board}/#{post['id']}" rel="nofollow">rep</a>
+ <?py if int(post['expires']): ?>
+ <small>${post['expires_formatted']}頃消えます</small>
+ <?py #endif ?>
+ <?py if int(post['parentid']) != 0: ?>
+ <?py if post['file']: ?>
+ <div class="fs">
+ <?py if post['image_width'] != '0': ?>
+ <a href="#{images_url}#{board}/src/#{post['file']}" class="expimg" target="_blank" data-id="#{post['id']}" data-thumb="#{images_url}#{board}/thumb/#{post['thumb']}" data-w="#{post['image_width']}" data-h="#{post['image_height']}" data-tw="#{post['thumb_width']}" data-th="#{post['thumb_height']}">#{post['file']}</a>-(#{post['file_size']} B, #{post['image_width']}x#{post['image_height']})
+ <?py else: ?>
+ <a href="#{images_url}#{board}/src/#{post['file']}" target="_blank">#{post['file']}</a>-(#{post['file_size']} B)
+ <?py #endif ?>
+ <?py if post['file'][-3:] == 'gif': ?>
+ <small>アニメGIF</small>
+ <?py elif not post['thumb'].startswith('mime'): ?>
+ <small>サムネ表示</small>
+ <?py #endif ?>
+ </div>
+ <a target="_blank" href="#{images_url}#{board}/src/#{post['file']}" id="thumb#{post['id']}">
+ <?py if post['thumb'].startswith('mime'): ?>
+ <img class="thumb" alt="#{post['id']}" src="/static/#{post['thumb']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" />
+ <?py elif post['file'][-3:] == 'gif': ?>
+ <img class="thumb" alt="#{post['id']}" src="#{images_url}#{board}/src/#{post['file']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" />
+ <?py else: ?>
+ <img class="thumb" alt="#{post['id']}" src="#{images_url}#{board}/thumb/#{post['thumb']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" />
+ <?py #endif ?>
+ </a>
+ <?py #endif ?>
+ <?py #endif ?>
+ <?py if int(post['parentid']) == 0 and not replythread: ?>
+ [<a href="#{boards_url}#{board}/res/#{post['id']}.html" class="hsbn">返信</a>]
+ <?py if post['file'] == '': ?>
+ [<a href="#" title="スレを隠す" class="tt">隠す</a>]
+ <?py #endif ?>
+ <?py #endif ?>
+ </div>
+ <?py if post['thumb_width'] != '0' and post['parentid'] != '0': ?>
+ <blockquote style="margin-left:#{int(post['thumb_width'])+40}px;">
+ <?py else: ?>
+ <blockquote>
+ <?py #endif ?>
+ #{post['message']}
+ </blockquote>
+ <?py if not replythread and post['shortened']: ?>
+ <blockquote class="abbrev">(投稿は長すぎ... 全部読むには<a href="#{boards_url}#{board}/res/#{post['id'] if post['parentid'] == "0" else post['parentid']}.html##{post['id']}">こっちら</a>へ)</blockquote>
+ <?py #endif ?>
+ <?py if int(post['expires_alert']): ?>
+ <div style="color:red;font-weight:bold">このスレは古いので、もうすぐ消えます。</div>
+ <?py #endif ?>
+ <?py #endif ?>
+ <?py if int(post['parentid']) == 0: ?>
+ <?py if not replythread: ?>
+ <?py if int(thread['omitted']) > 0: ?>
+ <span class="omitted">レス${thread['omitted']}件省略。全て読むには返信ボタンを押してください。</span>
+ <?py #endif ?>
+ <?py #endif ?>
+ <?py else: ?>
+ </td></tr></table>
+ <?py #endif ?>
+ <?py #endfor ?>
+ <div class="cut"></div></div>
+ <?py if replythread: ?>
+ <hr />
+ <div class="nav">&#91;<a href="#{boards_url}#{board}/">掲示板に戻る</a>&#93;
+ &#91;<a href="/cgi/catalog/${board}">カタログ</a>&#93;
+ &#91;<a href="#top" name="bottom">トップへ戻る</a>&#93;</div>
+ <?py #endif ?>
+ <hr />
+ <?py #endfor ?>
+ <div class="userdel">
+ 【記事削除】<label>[<input type="checkbox" name="imageonly" id="imageonly" />画像だけ消す]</label><br />
+ 削除キー<input type="password" name="password" size="8" /> <input name="deletepost" value="削除" type="submit" />
+ </div>
+ <?py #endif ?>
+</form>
+<?py if pagenav: ?>
+ <div class="pg">#{pagenav}</div>
+<?py #endif ?>
+<?py include('templates/base_bottom.html') ?> \ No newline at end of file
diff --git a/cgi/templates/catalog.html b/cgi/templates/catalog.html
new file mode 100644
index 0000000..4faa2d2
--- /dev/null
+++ b/cgi/templates/catalog.html
@@ -0,0 +1,30 @@
+<?py include('templates/base_top.html') ?>
+<div id="ctrl">
+ &#91;<a href="#{boards_url}#{board}/">Volver al IB</a>&#93;
+ &#91;Orden:
+ <a class="cat_sort" data-sort="0" href="?sort=0">#{"<b>Normal</b>" if i_sort == "" else "Normal"}</a>
+ <a class="cat_sort" data-sort="1" href="?sort=1">#{"<b>Nuevo</b>" if i_sort == "1" else "Nuevo"}</a>
+ <a class="cat_sort" data-sort="2" href="?sort=2">#{"<b>Viejo</b>" if i_sort == "2" else "Viejo"}</a>
+ <a class="cat_sort" data-sort="3" href="?sort=3">#{"<b>Más</b>" if i_sort == "3" else "Más"}</a>
+ <a class="cat_sort" data-sort="4" href="?sort=4">#{"<b>Menos</b>" if i_sort == "4" else "Menos"}</a>&#93;
+ &#91;Tamaño: <a id="cat_size" href="#">Pequeño</a>&#93;
+ &#91;Texto: <a id="cat_hide" href="#">Ocultar</a>&#93;
+ &#91;Buscar: <input id="cat_search" type="text"><input type="hidden" name="board" value="#{board}" />
+</div>
+<div class="extramode">Modo Catálogo</div>
+<div id="catalog" style="margin:1em auto;">
+ <?py i = 1 ?>
+ <?py for thread in threads: ?><div id="cat#{thread['id']}#{board}" class="thread" data-num="${i}" data-id="#{thread['id']}" data-res="${thread['length']}">
+ <?py if thread['thumb'] != '': ?>
+ <a href="#{boards_url}#{board}/res/#{thread['id']}.html" rel="nofollow"><img src="#{images_url}#{board}/cat/#{thread['thumb']}" alt="#{thread['id']}" /></a><br />
+ <?py #endif ?>
+ <div class="replies">Respuestas: ${thread['length']}</div>
+ <?py if thread['thumb'] != '': ?>
+ <p><span class="subj">${thread['subject']}</span><br />${thread['message']}</p>
+ <?py else: ?>
+ <p><a href="#{boards_url}#{board}/res/#{thread['id']}.html" rel="nofollow" class="subj">${thread['subject']}</a><br />${thread['message']}</p>
+ <?py #endif ?>
+ <?py i += 1 ?>
+ </div><?py #endfor ?>
+</div>
+<?py include('templates/base_bottom.html') ?> \ No newline at end of file
diff --git a/cgi/templates/error.html b/cgi/templates/error.html
new file mode 100644
index 0000000..47ef529
--- /dev/null
+++ b/cgi/templates/error.html
@@ -0,0 +1,7 @@
+<?py include('templates/base_top.html') ?>
+<br /><br /><hr size="1">
+<br /><br /><div style="text-align:center;color:red;font-size:x-large;font-weight:bold;">#{error}
+<br /><br /><a href="#{boards_url}#{board}/">Volver</a></div>
+<br /><br /><hr size="1">
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/exception.html b/cgi/templates/exception.html
new file mode 100644
index 0000000..e8453eb
--- /dev/null
+++ b/cgi/templates/exception.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+<title>Error@Bienvenido a Internet</title>
+<meta http-equiv="Content-Type" content="application/xhtml+xml; charset=UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<style type="text/css">.error{color:red;font-weight:bold;font-size:16pt} .sub{font-weight:bold}</style>
+</head>
+<body>
+<?py if exception: ?>
+<p class="error">ERROR : Ha ocurrido un error inesperado.</p>
+<p class="sub">Esto no es normal y te pedimos que reportes el problema en
+<a href="/bai/">Discusión de B.a.I.</a> o a través de
+<a href="mailto:burocracia@bienvenidoainternet.org">nuestro e-mail</a>,
+presentando los siguientes datos y ojalá indicando qué hacer para reproducirlo:</p>
+<p>Versión: weabot
+<?py include('templates/revision.html') ?><br />
+Tipo: ${exception}<br />
+Detalle: ${error}<br />
+Traceback:<br />
+<blockquote>
+ <?py for line in detail: ?>
+ ${line[0]} ${line[1]} ${line[2]} ${line[3]}<br />
+ <?py #endfor ?>
+</blockquote></p>
+<p class="sub">Te recordamos que el software está en desarrollo y estamos siempre haciendo lo posible para arreglar los problemas lo antes posible.<br />Te pedimos las disculpas por cualquier inconveniente.</p>
+<hr />
+<p>weabot dijo "Perdón."<br /><a href="/bai.html">Bienvenido a Internet BBS/IB</a></p>
+<?py else: ?>
+<p class="error">ERROR : #{error}</p>
+<p class="sub">Por favor presiona Atrás y soluciona el problema.</p>
+<hr />
+<p>La página principal está <a href="/bai.html">aquí</a>.<br />Si esto es inusual intenta <a href="/bai/">contactarnos</a>.</p><?py #endif ?>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/home.rss b/cgi/templates/home.rss
new file mode 100644
index 0000000..dc69377
--- /dev/null
+++ b/cgi/templates/home.rss
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<rss version="2.0">
+ <channel>
+ <title>Bienvenido a Internet BBS/IB</title>
+ <link>https://bienvenidoainternet.org/</link>
+ <description>El BBS/IB más activo de la esfera hispana.</description>
+ <language>es</language>
+ <webMaster>burocracia@bienvenidoainternet.org (Staff ★)</webMaster>
+ <image>
+ <url>https://bienvenidoainternet.org/rss_logo.png</url>
+ <title>Bienvenido a Internet BBS/IB</title>
+ <link>https://bienvenidoainternet.org/</link>
+ <width>144</width>
+ <height>144</height>
+ </image>
+<?py for post in posts: ?>
+ <item>
+ <title>${post['board_name']}: #{post['content']}</title>
+ <pubDate>${post['timestamp_formatted']}</pubDate>
+ <link>https://bienvenidoainternet.org#{post['url']}</link>
+ </item>
+<?py #endfor ?>
+ </channel>
+</rss> \ No newline at end of file
diff --git a/cgi/templates/htaccess b/cgi/templates/htaccess
new file mode 100644
index 0000000..469fec0
--- /dev/null
+++ b/cgi/templates/htaccess
@@ -0,0 +1,24 @@
+DirectoryIndex index.html
+<?py if dir == 'clusterfuck': ?>
+
+AuthName "BAI"
+AuthType Basic
+AuthUserFile "/home/z411/.htpasswds/public_html/wiki/passwd"
+<Limit GET>
+require valid-user
+</Limit>
+
+<?py #endif ?>
+<?py if dir == 'anarkia': ?>
+ExpiresByType text/css "access plus 0 seconds"
+<?py #endif ?>
+
+ErrorDocument 403 https://bienvenidoainternet.org/cgi/banned/#{dir}
+<?py if ips: ?>
+
+order allow,deny
+ <?py for ip in ips: ?>
+deny from #{ip}
+ <?py #endfor ?>
+allow from all
+<?py #endif ?>
diff --git a/cgi/templates/kako.html b/cgi/templates/kako.html
new file mode 100644
index 0000000..49d95df
--- /dev/null
+++ b/cgi/templates/kako.html
@@ -0,0 +1,60 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <title>Archivo de #{board_name}@Bienvenido a Internet BBS</title>
+ <meta http-equiv="Content-Type" content="application/xhtml+xml;charset=utf-8" />
+ <meta name="robots" content="index, follow" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <link rel="shortcut icon" href="#{static_url}img/favicon.ico" />
+ <style type="text/css">
+ body {margin:8px}
+ h1 {margin:0 0 20px}
+ pre {margin:0}
+ .fake {color:#0000EE;text-decoration:underline;cursor:pointer}
+ .fake:active {color:#FF0000}
+ img {width:20px;height:22px;margin-right:4px}
+ td {text-align:left;vertical-align:bottom;padding-right:14px}
+ .r {text-align:right}
+ a:link, a:hover {color:#0000EE}
+ a:active {color:#FF0000}
+ a:visited {color:#551A8B}
+ </style>
+</head>
+<body>
+<h1>Índice de /#{board}/kako/</h1>
+<pre>
+ <table style="border-collapse:collapse;">
+ <tr>
+ <th><img src="/blank.png" /></th>
+ <td><span class="fake">Nombre</span></td>
+ <td><span class="fake">Tamaño</span></td>
+ <td><span class="fake">Descripción</span></td>
+ </tr>
+ <tr>
+ <td colspan="4" style="padding:0"><hr /></td>
+ </tr>
+ <tr>
+ <th><img src="/back.png" /></th>
+ <td><a href="/#{board}/">..</a></td>
+ <td class="r">-</td>
+ <td></td>
+ </tr>
+ <?py for thread in threads: ?>
+ <tr>
+ <th><img src="/text.png" /></th>
+ <td><a href="#{boards_url}#{board}/read/#{thread['timestamp']}/">${thread['timestamp']}.json</a></td>
+ <?py if int(thread['length']) > 1000: ?>
+ <td class="r">1KR</td>
+ <?py else: ?>
+ <td class="r">${thread['length']}R</td>
+ <?py #endif ?>
+ <td>${thread['subject']}</td>
+ </tr>
+ <?py #endfor ?>
+ </table>
+ <hr />
+</pre>
+<address>weabot/0.8.4 (CentOS) Servidor ubicado en bienvenidoainternet.org Puerto 443</address>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/manage/addboard.html b/cgi/templates/manage/addboard.html
new file mode 100644
index 0000000..71b3c31
--- /dev/null
+++ b/cgi/templates/manage/addboard.html
@@ -0,0 +1,21 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<center>
+<div class="replymode">Nuevo board</div>
+<form action="#{cgi_url}manage/addboard" method="post">
+ <table>
+ <tr>
+ <td class="postblock">Directorio</td>
+ <td><input type="text" name="dir" maxlength="16" style="width:100%;" /></td>
+ </tr>
+ <tr>
+ <td class="postblock">Nombre</td>
+ <td><input type="text" name="name" maxlength="64" style="width:100%;" /></td>
+ </tr>
+ <tr>
+ <td colspan="2"><input type="submit" name="submit" style="width:100%;" value="Agregar board" /></td>
+ </table>
+</form>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?> \ No newline at end of file
diff --git a/cgi/templates/manage/bans.html b/cgi/templates/manage/bans.html
new file mode 100644
index 0000000..81e0f71
--- /dev/null
+++ b/cgi/templates/manage/bans.html
@@ -0,0 +1,92 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<?py from tenjin.helpers.html import * ?>
+<center>
+<div class="replymode">Bans</div>
+<?py if mode == 0: ?>
+<form action="#{cgi_url}manage/ban/" name="banform" method="post">
+<table>
+<tr>
+ <td class="postblock">Dirección IP</td>
+ <td><input type="text" name="ip" size="20" /></td>
+</tr>
+<tr><td colspan="2"><input type="submit" value="Ir a formulario de ban" style="width:100%;" /></td></tr>
+</table>
+</form>
+<hr />
+<table class="managertable">
+<tr>
+ <th>Dirección IP</th>
+ <th>Máscara de red</th>
+ <th>Boards</th>
+ <th>Agregado</th>
+ <th>Expira</th>
+ <th>Ciego</th>
+ <th>Puesto por</th>
+ <th>Razón</th>
+ <th>Nota</th>
+ <th>Acción</th>
+</tr>
+<?py for ban in bans: ?>
+<tr>
+ <td>${ban['ip']}</td>
+ <td>${ban['netmask']}</td>
+ <td>${ban['boards']}</td>
+ <td>${ban['added']}</td>
+ <td>${ban['until']}</td>
+ <td>${ban['blind']}</td>
+ <td>${ban['staff']}</td>
+ <td>${ban['reason']}</td>
+ <td>${ban['note']}</td>
+ <td>
+ [<a href="#{cgi_url}manage/ipshow?ip=#{ban['ip']}">Ver posts</a>]
+ [<a href="#{cgi_url}manage/ban?ip=#{ban['ip']}&amp;edit=#{ban['id']}">Editar</a>]
+ [<a href="#{cgi_url}manage/bans/delete/#{ban['id']}">Eliminar</a>]
+ </td>
+</tr>
+<?py #endfor ?>
+</table>
+<?py elif mode == 1: ?>
+<form action="#{cgi_url}manage/ban" name="banform" method="post">
+<table>
+ <tr><td class="postblock">IP</td><td><input type="text" name="ip" value="${ip}" size="20" style="width:100%;" /></td></tr>
+ <tr><td class="postblock">Máscara de red</td><td><input type="text" name="netmask" value="${startvalues['netmask']}" style="width:100%;" /></td></tr>
+ <tr>
+ <td class="postblock">Board(s)</td>
+ <td>
+ <input type="checkbox" name="board_all" id="b_all" value="1"#{checked(startvalues['where'] == '')} /><label for="b_all" style="font-weight:bold">Todos los boards</label><hr />
+ <?py for board in boards: ?>
+ <input type="checkbox" name="board_#{board['dir']}" id="b#{board['dir']}" value="1"#{checked(board['dir'] in startvalues['where'])} /><label for="b#{board['dir']}">${board['name']}</label><br />
+ <?py #endfor ?>
+ <?py if edit_id > 0: ?>
+ <input type="hidden" name="edit" value="${edit_id}" />
+ <?py #endif ?>
+ </td>
+ </tr>
+ <tr><td class="postblock">Mensaje</td><td><textarea name="reason" style="width:100%;">${startvalues['reason']}</textarea></td></tr>
+ <tr><td class="postblock">Nota para staff</td><td><input type="text" name="note" value="${startvalues['note']}" style="width:100%;" /></td></tr>
+ <tr><td class="postblock">Ciego</td><td><input type="checkbox" name="blind" id="blind" value="1"#{checked(startvalues['blind'] == '1')} /><label for="blind"></label></td></tr>
+ <tr><td class="postblock">Expira en <span style="font-weight:normal;">(segundos)</span></td><td><input type="text" id="seconds" name="seconds" value="#{startvalues['seconds']}" style="width:100%;" />
+ <br />
+ <div id="timelist">
+ <a href="#" data-secs="0">Nunca</a>
+ <a href="#" data-secs="3600">1h</a>
+ <a href="#" data-secs="21600">6h</a>
+ <a href="#" data-secs="43200">12h</a>
+ <a href="#" data-secs="86400">1d</a>
+ <a href="#" data-secs="259200">3d</a>
+ <a href="#" data-secs="604800">1w</a>
+ <a href="#" data-secs="2592000">30d</a>
+ <a href="#" data-secs="31536000">1y</a>
+ </div>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="2"><input type="submit" value="Colocar ban" style="width:100%;" /></td>
+ </tr>
+</table>
+</form>
+<?py #endif ?>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?> \ No newline at end of file
diff --git a/cgi/templates/manage/boardoptions.html b/cgi/templates/manage/boardoptions.html
new file mode 100644
index 0000000..436b036
--- /dev/null
+++ b/cgi/templates/manage/boardoptions.html
@@ -0,0 +1,195 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<?py from tenjin.helpers.html import * ?>
+<center>
+<div class="replymode">Opciones de Board</div>
+<?py if mode == 0: ?>
+<table class="managertable">
+ <tr><th colspan="2">Sección</th><th>Accion</th></tr>
+ <?py for board in boards: ?>
+ <tr><td>/#{board['dir']}/</td><td>#{board['name']}</td><td>[<a href="#{cgi_url}manage/board/#{board['dir']}">Configurar</a>]</td></tr>
+ <?py #endfor ?>
+ </table>
+<?py elif mode == 1: ?>
+<form action="#{cgi_url}manage/board/${boardopts['dir']}" method="post">
+<table>
+<tr>
+ <td class="postblock">ID</td>
+ <td><input type="text" name="name" value="${boardopts['id']}" maxlength="16" style="width:100%;" disabled="disabled" /></td>
+</tr>
+<tr>
+ <td class="postblock">Directorio</td>
+ <td><input type="text" name="name" value="${boardopts['dir']}" maxlength="32" style="width:100%;" disabled="disabled" /></td>
+</tr>
+<tr>
+ <td class="postblock">Nombre</td>
+ <td><input type="text" name="name" value="${boardopts['name']}" maxlength="64" style="width:100%;" /></td>
+</tr>
+<tr>
+<td class="postblock">Nombre largo</td>
+<td><input type="text" name="longname" size="50" value="${boardopts['longname']}" maxlength="128" style="width:100%;" /></td>
+</tr>
+<tr>
+<td class="postblock">Sub-nombre</td>
+<td><input type="text" name="subname" value="${boardopts['subname']}" maxlength="3" style="width:100%;" /></td>
+</tr>
+<tr>
+<td class="postblock">Tipo</td>
+<td>
+ <select style="width:100%;" name="type">
+ <option value="0">Imageboard</option>
+ <option value="1"#{selected(boardopts['board_type'] == '1')}>Textboard</option>
+ </select>
+</td>
+</tr>
+<tr>
+<td class="postblock">Descripción / Reglas</td>
+<td>
+ <textarea id="brd_desc" name="postarea_desc" rows="10" cols="50" style="width:100%;">${boardopts['postarea_desc']}</textarea>
+ <div id="prev_desc" style="border:1px dotted gray;display:none;padding:4px;width:100%;" contenteditable="true"></div>
+</td>
+</tr>
+<tr>
+<td class="postblock">Caja extra</td>
+<td><textarea name="postarea_extra" rows="5" cols="50" style="width:100%;">${boardopts['postarea_extra']}</textarea></td>
+</tr>
+<tr>
+<td class="postblock">Forzar CSS <span style="font-weight:normal;">("" = default)</span></td>
+<td><input type="text" name="force_css" size="50" value="#{boardopts['force_css']}" maxlength="255" style="width:100%;" /></td>
+</tr>
+<tr>
+<td class="postblock">Nombre por defecto</td>
+<td><input type="text" name="anonymous" size="50" maxlength="128" value="${boardopts['anonymous']}" style="width:100%;" /></td>
+</tr>
+<tr>
+<td class="postblock">Título por defecto</td>
+<td><input type="text" name="subject" size="50" maxlength="64" value="${boardopts['subject']}" style="width:100%;" /></td>
+</tr>
+<tr>
+<td class="postblock">Mensaje por defecto</td>
+<td><input type="text" name="message" size="50" maxlength="128" value="${boardopts['message']}" style="width:100%;" /></td>
+</tr>
+<tr>
+<td class="postblock">ID</td>
+<td>
+ <select name="useid" style="width:100%;">
+ <option value="0">Desactivado</option>
+ <option value="1"#{selected(boardopts['useid'] == '1')}>Activado</option>
+ <option value="2"#{selected(boardopts['useid'] == '2')}>Activado siempre</option>
+ <option value="3"#{selected(boardopts['useid'] == '3')}>Activado siempre, detallado</option>
+ </select>
+</td>
+</tr>
+<tr>
+<td class="postblock">Slip</td>
+<td>
+ <select name="slip" style="width:100%;">
+ <option value="0">Desactivado</option>
+ <option value="1"#{selected(boardopts['slip'] == '1')}>Activado</option>
+ <option value="2"#{selected(boardopts['slip'] == '2')}>Sólo dominio</option>
+ <option value="3"#{selected(boardopts['slip'] == '3')}>Todo</option>
+ </select>
+</td>
+</tr>
+<tr>
+<td class="postblock">Código de país</td>
+<td>
+ <select name="countrycode" style="width:100%;">
+ <option value="0">Desactivado</option>
+ <option value="1"#{selected(boardopts['countrycode'] == '1')}>Activado</option>
+ </select>
+</td>
+</tr>
+<tr>
+<td class="postblock">Desactivar nombre</td>
+<td><input type="checkbox" name="disable_name" id="noname" value="1"#{checked(boardopts['disable_name'] == '1')} /><label for="noname"></label></td>
+</tr>
+<tr>
+<td class="postblock">Desactivar asunto</td>
+<td><input type="checkbox" name="disable_subject" id="nosub" value="1"#{checked(boardopts['disable_subject'] == '1')} /><label for="nosub"></label></td>
+</tr>
+<tr>
+<td class="postblock">Papelera de reciclaje</td>
+<td><input type="checkbox" name="recyclebin" id="bin" value="1"#{checked(boardopts['recyclebin'] == '1')} /><label for="bin"></label></td>
+</tr>
+<tr>
+<td class="postblock">Cerrado</td>
+<td><input type="checkbox" name="locked" id="locked" value="1"#{checked(boardopts['locked'] == '1')} /><label for="locked"></label></td>
+</tr>
+<tr>
+<td class="postblock">Secreto</td>
+<td><input type="checkbox" name="secret" id="secret" value="1"#{checked(boardopts['secret'] == '1')} /><label for="secret"></label></td>
+</tr>
+<tr>
+<td class="postblock">Permitir spoilers</td>
+<td><input type="checkbox" name="allow_spoilers" id="spoil" value="1"#{checked(boardopts['allow_spoilers'] == '1')} /><label for="spoil"></label></td>
+</tr>
+<tr>
+<td class="postblock">Permitir oekaki</td>
+<td><input type="checkbox" name="allow_oekaki" id="oek" value="1"#{checked(boardopts['allow_oekaki'] == '1')} /><label for="oek"></label></td>
+</tr>
+<tr>
+<td class="postblock">Permitir crear hilos sin imagen</td>
+<td><input type="checkbox" name="allow_noimage" id="noimgallow" value="1"#{checked(boardopts['allow_noimage'] == '1')} /><label for="noimgallow"></label></td>
+</tr>
+<tr>
+<td class="postblock">Permitir subida</td>
+<td><input type="checkbox" name="allow_images" id="img" value="1"#{checked(boardopts['allow_images'] == '1')} /><label for="img">Al crear un hilo</label><br /><input type="checkbox" name="allow_image_replies" id="imgres" value="1"#{checked(boardopts['allow_image_replies'] == '1')} /><label for="imgres">Al responder</label></td>
+</tr>
+<tr>
+<td class="postblock">Tipos de archivo</td>
+<td>
+ <?py for filetype in filetypes: ?>
+ <input type="checkbox" name="filetype#{filetype['ext']}" id="#{filetype['ext']}" value="1"#{checked(filetype['ext'] in supported_filetypes)} /><label for="#{filetype['ext']}">${filetype['ext'].upper()}</label><br />
+ <?py #endfor ?>
+</td>
+</tr>
+<tr>
+<td class="postblock">Tamaño máximo <span style="font-weight:normal;">(KB)</span></td>
+<td><input type="text" name="maxsize" value="#{boardopts['maxsize']}" maxlength="5" size="11" /></td>
+</tr>
+<tr>
+<td class="postblock">Dimensión de miniatura <span style="font-weight:normal;">(px)</span></td>
+<td><input type="text" name="thumb_px" value="#{boardopts['thumb_px']}" maxlength="3" size="11" /></td>
+</tr>
+<tr>
+<td class="postblock">Hilos en página frontal</td>
+<td><input type="text" name="numthreads" value="#{boardopts['numthreads']}" maxlength="2" size="11" /></td>
+</tr>
+<tr>
+<td class="postblock">Respuestas a mostrar</td>
+<td><input type="text" name="numcont" value="#{boardopts['numcont']}" maxlength="2" size="11" /></td>
+</tr>
+<tr>
+<td class="postblock">Máximo de líneas <span style="font-weight:normal;">(frontal)</span></td>
+<td><input type="text" name="numline" value="#{boardopts['numline']}" maxlength="3" size="11" /></td>
+</tr>
+<tr>
+<td class="postblock">Edad máxima de un hilo</td>
+<td><input type="text" name="maxage" value="#{boardopts['maxage']}" maxlength="3" size="11" /> (días; 0 = desactivar)</td>
+</tr>
+<tr>
+<td class="postblock">Inactividad máxima de un hilo</td>
+<td><input type="text" name="maxinactive" value="#{boardopts['maxinactive']}" maxlength="3" size="11" /> (días; 0 = desactivar)</td>
+</tr>
+<tr>
+<td class="postblock">Archivar hilos</td>
+<td><input type="checkbox" name="archive" id="arch" value="1"#{checked(boardopts['archive'] == '1')} /><label for="arch"></label></td>
+</tr>
+<tr>
+<td class="postblock">Espera para crear nuevo hilo</td>
+<td><input type="text" name="threadsecs" value="#{boardopts['threadsecs']}" maxlength="4" size="11" /> (segundos)</td>
+</tr>
+<tr>
+<td class="postblock">Espera entre respuestas</td>
+<td><input type="text" name="postsecs" value="#{boardopts['postsecs']}" maxlength="3" size="11" /> (segundos)</td>
+</tr>
+</table>
+<br />
+<hr />
+<input type="submit" value="Guardar cambios" />
+</form>
+<?py #endif ?>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?> \ No newline at end of file
diff --git a/cgi/templates/manage/changepassword.html b/cgi/templates/manage/changepassword.html
new file mode 100644
index 0000000..977c772
--- /dev/null
+++ b/cgi/templates/manage/changepassword.html
@@ -0,0 +1,24 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<center>
+<div class="replymode">Cambiar contraseña</div>
+<form action="#{cgi_url}manage/changepassword" method="post">
+<table>
+ <tr>
+ <td class="postblock">Clave actual</td>
+ <td><input type="password" name="oldpassword" style="width:100%;" /></td>
+ </tr>
+ <tr>
+ <td class="postblock">Nueva clave</td>
+ <td><input type="password" name="newpassword" style="width:100%;" /></td>
+ </tr>
+ <tr>
+ <td class="postblock">Confirmar nueva clave</td>
+ <td><input type="password" name="newpassword2" style="width:100%;" /></td>
+ </tr>
+ <tr><td colspan="2"><input type="submit" style="width:100%;" value="Cambiar" /></td></tr>
+</table>
+</form>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?>
diff --git a/cgi/templates/manage/delete.html b/cgi/templates/manage/delete.html
new file mode 100644
index 0000000..78c1c5e
--- /dev/null
+++ b/cgi/templates/manage/delete.html
@@ -0,0 +1,23 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<center>
+<div class="replymode">Eliminar Post</div>
+<form action="#{cgi_url}manage/delete_confirmed/#{curboard}/#{postid}" method="get">
+<?py if do_ban: ?>
+ <input type="hidden" name="ban" value="true" />
+<?py #endif ?>
+<p>
+ <b>Post #${postid} de /${curboard}/</b><br />
+ <input id="a" type="checkbox" name="imageonly" value="true" /><label for="a">Eliminar sólo archivo</label><br />
+ <input id="b" type="checkbox" name="perma" value="true" /><label for="b" style="font-weight:bold">Eliminar permanentemente</label><br />
+ <br />
+ <i>Nota: Por favor evitar eliminar <b>permanentemente</b> el post al menos que sea estrictamente necesario.
+ <br />Al eliminar permanentemente un post no queda en papelera y se rompen
+ las referencias que se pueden haber hecho hacia él, especialmente en los BBS.</i>
+ <br /><br />
+ <input type="submit" value="Eliminar" />
+</p>
+</form>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?>
diff --git a/cgi/templates/manage/filters.html b/cgi/templates/manage/filters.html
new file mode 100644
index 0000000..188a741
--- /dev/null
+++ b/cgi/templates/manage/filters.html
@@ -0,0 +1,119 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<?py from tenjin.helpers.html import * ?>
+<center>
+<div class="replymode">Filtros</div>
+<?py if mode == 0: ?>
+<table class="managertable">
+ <tr>
+ <th>ID</th>
+ <th>Boards</th>
+ <th>Tipo</th>
+ <th>Acción</th>
+ <th>Mensaje</th>
+ <th>Modificado</th>
+ <th>Por</th>
+ <th>Acción</th>
+ </tr>
+ <?py for filter in filters: ?>
+ <tr>
+ <td style="text-align:center">#{filter['id']}</td>
+ <td style="text-align:center">${filter['boards']}</td>
+ <td>#{filter['type_formatted']}</td>
+ <td>#{filter['action_formatted']}</td>
+ <td>${filter['reason']}</td>
+ <td style="text-align:center">${filter['added']}</td>
+ <td style="text-align:center">${filter['staff']}</td>
+ <td style="text-align:center">[<a href="#{cgi_url}manage/filters/add?edit=#{filter['id']}">Editar</a>]<br />
+ [<a href="#{cgi_url}manage/filters/delete/#{filter['id']}">Eliminar</a>]</td>
+ </tr>
+ <?py #endfor ?>
+ <tr><td colspan="9" style="text-align:center">
+ <form action="#{cgi_url}manage/filters/add" method="get">
+ <input type="submit" value="Agregar filtro" />
+ </form></td>
+ </tr>
+</table>
+<?py elif mode == 1: ?>
+<form name="banform" method="post">
+<table>
+ <tr><th colspan="3" class="postblock">Tipo de filtro</th></tr>
+ <tr>
+ <td class="postblock"><input type="radio" name="type" id="type1" value="0"#{checked(startvalues['type'] == '0')} /><label for="type1">Palabra</label></td>
+ <td style="text-align:right">Regex:</td>
+ <td><input type="text" name="word" value="${startvalues['word']}" /></td>
+ </tr>
+ <tr>
+ <td rowspan="2" class="postblock"><input type="radio" name="type" id="type2" value="1"#{checked(startvalues['type'] == '1')} /><label for="type2">Nombre/Tripcode</label></td>
+ <td style="text-align:right">Nombre:</td>
+ <td><input type="text" name="name" value="${startvalues['name']}" /> (regex)</td>
+ </tr>
+ <tr>
+ <td style="text-align:right">Tripcode:</td>
+ <td><input type="text" name="trip" value="${startvalues['trip']}" /> (incluir separador)</td>
+ </tr>
+</table>
+<br />
+<div style="text-align:left;display:inline-block;">
+ <div class="postblock" style="display:block;text-align:center;margin-bottom:0.5em;">Aplicar a</div>
+ <div style="padding:0 10px">
+ <input type="checkbox" name="board_all" id="board_all" value="1"#{checked(startvalues['where'] == '')} /><label for="board_all" style="font-weight:bold">Todos los boards</label>
+ <hr />
+ <?py for board in boards: ?>
+ <input type="checkbox" name="board_#{board['dir']}" id="board_#{board['dir']}" value="1"#{checked(board['dir'] in startvalues['where'])} /><label for="board_#{board['dir']}">${board['name']} <span style="opacity:0.5">(/#{board['dir']}/)</span></label><br />
+ <?py #endfor ?>
+ </div>
+</div>
+<br /><br />
+<table>
+ <tr>
+ <th colspan="3" class="postblock">Acción</th>
+ </tr>
+ <tr>
+ <td class="postblock"><input type="radio" name="action" id="act0" value="0"#{checked(startvalues['action'] == '0')} /><label for="act0">Abortar post</label></td>
+ <td colspan="2"></td>
+ </tr>
+ <tr>
+ <td class="postblock"><input type="radio" name="action" id="act1" value="1"#{checked(startvalues['action'] == '1')} /><label for="act1">Reemplazar</label></td>
+ <td colspan="2"><input type="text" name="changeto" value="#{startvalues['changeto']}" size="40" /></td>
+ </tr>
+ <tr>
+ <td rowspan="2" class="postblock"><input type="radio" name="action" id="act2" value="2"#{checked(startvalues['action'] == '2')} /><label for="act2">Autoban</label></td>
+ <td style="text-align:right">Expira en:</td>
+ <td><input type="text" name="seconds" id="seconds" size="6" value="#{startvalues['seconds']}" /> (segundos)<div style="float:right"><input type="checkbox" name="blind" id="blind" value="1"#{checked(startvalues['blind'] == '1')} /><label for="blind">Ban ciego</label></div></td>
+ </tr>
+ <tr>
+ <td style="text-align:right">Preset:</td>
+ <td id="timelist">
+ <a href="#" data-secs="0">Nunca</a>
+ <a href="#" data-secs="3600">1h</a>
+ <a href="#" data-secs="21600">6h</a>
+ <a href="#" data-secs="43200">12h</a>
+ <a href="#" data-secs="86400">1d</a>
+ <a href="#" data-secs="259200">3d</a>
+ <a href="#" data-secs="604800">1w</a>
+ <a href="#" data-secs="2592000">30d</a>
+ <a href="#" data-secs="31536000">1y</a>
+ </td>
+ </tr>
+ <tr>
+ <td rowspan="2" class="postblock"><input type="radio" name="action" id="act3" value="3"#{checked(startvalues['action'] == '3')} /><label for="act3">Redireccionar</label></td>
+ <td colspan="2"><input type="text" name="redirect_url" value="#{startvalues['redirect_url']}" size="40" /></td>
+ </tr>
+ <tr>
+ <td style="text-align:right">Tardar:</td>
+ <td><input type="text" name="redirect_time" size="6" value="#{startvalues['redirect_time']}" /> (segundos)</td>
+ </tr>
+</table>
+<br />
+<table>
+ <tr><th class="postblock" style="padding:2px">Mensaje a mostrar</th></tr>
+ <tr><td><input type="text" size="50" name="reason" value="#{startvalues['reason']}" /></td></tr>
+</table>
+<br />
+<input type="submit" name="add" value="#{submit}" />
+</form>
+<?py #endif ?>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?>
diff --git a/cgi/templates/manage/ipdelete.html b/cgi/templates/manage/ipdelete.html
new file mode 100644
index 0000000..71c043a
--- /dev/null
+++ b/cgi/templates/manage/ipdelete.html
@@ -0,0 +1,24 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<center>
+ <div class="replymode">Eliminar por IP</div>
+ <form action="#{cgi_url}manage/ipdelete" name="ipdeleteform" method="post">
+ <table>
+ <tr>
+ <td class="postblock">Board(s)</td>
+ <td>
+ <input type="checkbox" name="board_all" id="all" value="1" /><label for="all" style="font-weight:bold">Todos los boards</label><hr />
+ <?py for board in boards: ?>
+ <input type="checkbox" name="board_#{board['dir']}" id="#{board['dir']}" value="1" /><label for="#{board['dir']}">#{board['name']} <span style="opacity:0.5">(/#{board['dir']}/)</span></label><br />
+ <?py #endfor ?>
+ </td>
+ </tr>
+ <tr>
+ <td class="postblock">Dirección IP</td>
+ <td><input type="text" name="ip" style="width:100%;" /></td>
+ </tr>
+ <tr><td colspan="2"><input type="submit" style="width:100%;" value="Eliminar posts" /></td></tr>
+ </table>
+ </form>
+</center><hr />
+<?py include('templates/base_bottom.html') ?>
diff --git a/cgi/templates/manage/ipshow.html b/cgi/templates/manage/ipshow.html
new file mode 100644
index 0000000..6937a0e
--- /dev/null
+++ b/cgi/templates/manage/ipshow.html
@@ -0,0 +1,73 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<center>
+ <div class="replymode">Mostrar por IP</div>
+ <?py if mode == 0: ?>
+ <form action="#{cgi_url}manage/ipshow" method="post">
+ <table>
+ <tr><td class="postblock">Dirección IP</td><td><input type="text" name="ip" /></td></tr>
+ <tr><td colspan="2"><input type="submit" style="width:100%;" value="Mostrar posts" /></td></tr>
+ </table>
+ </form>
+ <?py else: ?>
+ <style>td img{max-width:150px;height:auto;}td.z{padding:0}</style>
+ <div class="logo" style="margin:0;">Actividad IP #{ip} (#{len(posts)})</div>
+ <center>
+ Hostname: #{host if host else "Desconocido"} [#{country if country else "??"}]#{" (Nodo Tor)" if tor else ""}<br />
+ <br />
+ <form action="#{cgi_url}manage/ban/" name="banform" method="post"><input type="hidden" name="ip" value="${ip}" /><input type="submit" value="Ir a formulario de ban" /></form>
+ <hr />
+ <?py if posts: ?>
+ <table class="managertable">
+ <tr>
+ <th>Sección</th>
+ <th>Padre</th>
+ <th>ID</th>
+ <th>Fecha</th>
+ <th>Nombre</th>
+ <th>Asunto</th>
+ <th>Mensaje</th>
+ <th>Archivo</th>
+ <th>Acción</th>
+ </tr>
+ <?py for post in posts: ?>
+ <tr>
+ <td>#{post['dir']}</td>
+ <td>#{post['parentid']}</td>
+ <td>#{post['id']}</td>
+ <td class="date" data-unix="${post['timestamp']}">#{post['timestamp_formatted']}</td>
+ <?py if post['tripcode']: ?>
+ <td class="name"><b>#{post['name']}</b> #{post['tripcode']}</td>
+ <?py else: ?>
+ <td class="name"><b>#{post['name']}</b></td>
+ <?py #endif ?>
+ <td>#{post['subject']}</td>
+ <td>#{post['message']}</td>
+ <?py if post['file']: ?>
+ <td class="z"><img src="#{images_url}#{post['dir']}/thumb/#{post['thumb']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" /></td>
+ <?py else: ?>
+ <td></td>
+ <?py #endif ?>
+ <td>
+ <?py if post['IS_DELETED'] == '0': ?>
+ <a href="#{cgi_url}manage/delete/#{post['dir']}/#{post['id']}">Eliminar</a>
+ <?py elif post['IS_DELETED'] == '1': ?>
+ <a href="#{cgi_url}manage/recyclebin/0/restore/#{post['dir']}/#{post['id']}">Rec</a>
+ <abbr title="Eliminado por usuario">[1]</abbr>
+ <?py else: ?>
+ <a href="#{cgi_url}manage/recyclebin/0/restore/#{post['dir']}/#{post['id']}">Rec</a>
+ <abbr title="Eliminado por staff">[2]</abbr>
+ <?py #endif ?>
+ </td>
+ </tr>
+ <?py #endfor ?>
+ </table>
+ <hr />
+ <?py else: ?>
+ <b>Error:</b> No hay posts<br /><br />
+ <?py #endif ?>
+ [<a href="#{cgi_url}manage/ipshow">Volver al panel</a>]
+ <?py #endif ?>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?> \ No newline at end of file
diff --git a/cgi/templates/manage/lockboard.html b/cgi/templates/manage/lockboard.html
new file mode 100644
index 0000000..cebf061
--- /dev/null
+++ b/cgi/templates/manage/lockboard.html
@@ -0,0 +1,20 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<center>
+<div class="replymode">Cerrar o abrir board</div>
+<table class="managertable">
+ <tr><th colspan="2">Sección</th><th>Acción</th></tr>
+ <?py for board in boards: ?>
+ <tr>
+ <td>/#{board['dir']}/</td><td>#{board['name']}</td>
+ <?py if board['locked'] == '0': ?>
+ <td style="text-align:center;">[<a href="#{cgi_url}manage/boardlock/#{board['dir']}">Cerrar</a>]</td>
+ <?py elif board['locked'] == '1': ?>
+ <td style="text-align:center;">[<a href="#{cgi_url}manage/boardlock/#{board['dir']}">Abrir</a>]</td>
+ <?py #endif ?>
+ </tr>
+ <?py #endfor ?>
+</table>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?>
diff --git a/cgi/templates/manage/login.html b/cgi/templates/manage/login.html
new file mode 100644
index 0000000..7ce47a1
--- /dev/null
+++ b/cgi/templates/manage/login.html
@@ -0,0 +1,21 @@
+<?py include('templates/base_top.html') ?>
+<center>
+ #{page}
+ <form action="#{cgi_url}manage" method="post">
+ <table>
+ <tr>
+ <td class="postblock">Usuario</td>
+ <td><input type="text" name="username" /></td>
+ </tr>
+ <tr>
+ <td class="postblock">Contraseña</td>
+ <td><input type="password" name="password" /></td>
+ </tr>
+ <tr>
+ <td colspan="2"><input id="submit" type="submit" name="submit" style="width:100%;" value="Entrar" /></td>
+ </tr>
+ </table>
+ </form>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?> \ No newline at end of file
diff --git a/cgi/templates/manage/logs.html b/cgi/templates/manage/logs.html
new file mode 100644
index 0000000..e11780a
--- /dev/null
+++ b/cgi/templates/manage/logs.html
@@ -0,0 +1,17 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<center>
+<div class="replymode">Registro</div>
+<table class="managertable">
+ <tr><th>Fecha</th><th>Staff</th><th>Acción</th></tr>
+<?py for log in logs: ?>
+ <tr>
+ <td class="date" data-unix="${log['timestamp']}" style="white-space:nowrap;">${log['timestamp_formatted']}</td>
+ <td>${log['staff']}</td>
+ <td>${log['action']}</td>
+ </tr>
+<?py #endfor ?>
+</table>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?> \ No newline at end of file
diff --git a/cgi/templates/manage/manage.html b/cgi/templates/manage/manage.html
new file mode 100644
index 0000000..06b1737
--- /dev/null
+++ b/cgi/templates/manage/manage.html
@@ -0,0 +1,22 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<center>
+ <div style="margin:0.5em 0;"><strong>BANDEJA DE ENTRADA</strong>
+ <br />
+ Denuncias:
+ <?py if int(reports) > 0: ?>
+ <a href="#{cgi_url}manage/reports" style="color:red;font-weight:bold;">#{reports}</a>
+ <?py else: ?>
+ 0
+ <?py #endif ?></div>
+ <hr />
+ <strong>NOTICIAS DEL STAFF</strong>
+</center>
+<dl style="margin:0 2.5%">
+<?py for post in posts: ?>
+ <dt><strong>#{post['title'] if post['title'] else "Sin asunto"}</strong><br />#{post['id']} : <b class="name">${post['name']}</b> : <span class="date" data-unix="${post['timestamp']}"}>${post['timestamp_formatted']}</span></dt>
+ <dd style="margin-bottom:1em;">#{post['message']}</dd>
+<?py #endfor ?>
+</dl>
+<hr />
+<?py include('templates/base_bottom.html') ?> \ No newline at end of file
diff --git a/cgi/templates/manage/menu.html b/cgi/templates/manage/menu.html
new file mode 100644
index 0000000..d6ffd5e
--- /dev/null
+++ b/cgi/templates/manage/menu.html
@@ -0,0 +1,30 @@
+<style>#adminmenu {text-align:center;}#adminmenu table {display:inline-block;font-size:10pt;margin-top:2px;text-align:left;}
+#adminmenu a {font-weight:bold;}label {vertical-align:top;}dd p {margin:0;}</style>
+<script type="text/javascript" src="/static/js/manage.js"></script>
+<input type="hidden" name="board" value="" />
+<?py if int(rights) < 4: ?>
+<div id="adminmenu">¡Bienvenido, <b><acronym title="Cuenta creada el #{added}">#{username}</acronym></b>! ¡Eres
+<?py if rights == '0': ?><b>Accionista</b>
+<?py elif rights == '1': ?><b>Accionista</b>
+<?py elif rights == '2': ?><span class="developer">Developer</span>
+<?py elif rights == '3': ?><span class="moderator">Moderador</span>
+<?py #endif ?> de #{site_title}!<br />
+<center>
+<table class="reply">
+<tr><td>Principal:</td>
+<td>- <a href="#{cgi_url}manage">Inicio</a> - <a href="#{cgi_url}manage/changepassword">Cambiar contrase&ntilde;a</a> - <a href="#{cgi_url}manage/newschannel">News Channel</a> - <a href="//webmail.bienvenidoainternet.org">Correo</a> - <a href="#{cgi_url}manage/logout">Cerrar sesi&oacute;n</a> -</td></tr>
+<tr><td>Posts:</td>
+<td>- <a href="#{cgi_url}manage/mod">Modbrowse</a> - <a href="#{cgi_url}manage/ipshow">Ver por IP</a> - <a href="#{cgi_url}manage/recyclebin">Papelera de reciclaje</a> - <a href="#{cgi_url}manage/recent_images">Im&aacute;genes recientes</a> -</td></tr>
+<tr><td>Moderaci&oacute;n:</td>
+<td>- <a href="#{cgi_url}manage/reports">Denuncias</a> - <a href="#{cgi_url}manage/ipdelete">Eliminar por IP</a> - <a href="#{cgi_url}manage/bans">Lista de bans</a> - <a href="#{cgi_url}manage/move">Mover hilo</a> - <a href="#{cgi_url}manage/filters">Filtros</a> - <a href="#{cgi_url}manage/quotes">Frases</a> -</td></tr>
+<?py if int(rights) < 3: ?>
+<tr><td>Administraci&oacute;n:</td>
+<td>- <a href="#{cgi_url}manage/rebuild">Reconstruir</a> - <a href="#{cgi_url}manage/news?type=1">Noticias</a> - <a href="#{cgi_url}manage/news?type=2">Twitter</a> - <a href="#{cgi_url}manage/board">Opciones de board</a> - <a href="#{cgi_url}manage/addboard">Agregar board</a> - <a href="#{cgi_url}manage/lockboard">Cerrar board</a> -</td></tr>
+<?py if int(rights) in [0,2]: ?>
+<tr><td>Staff:</td>
+<td>- <a href="#{cgi_url}manage/staff">Miembros</a> - <a href="#{cgi_url}manage/logs">Registro de acciones</a> -</td></tr>
+<?py #endif ?>
+<?py #endif ?>
+</table></center></div>
+<hr />
+<?py #endif ?> \ No newline at end of file
diff --git a/cgi/templates/manage/message.html b/cgi/templates/manage/message.html
new file mode 100644
index 0000000..6c53ecc
--- /dev/null
+++ b/cgi/templates/manage/message.html
@@ -0,0 +1,8 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<center>
+ <div class="replymode">#{title if title else "Mensaje"}</div>
+ <p>#{message}</p>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?> \ No newline at end of file
diff --git a/cgi/templates/manage/mod.html b/cgi/templates/manage/mod.html
new file mode 100644
index 0000000..ddc688f
--- /dev/null
+++ b/cgi/templates/manage/mod.html
@@ -0,0 +1,96 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<center>
+<div class="replymode">Modbrowse</div>
+<?py if mode == 1: ?>
+<table class="managertable">
+ <tr><th colspan="2">Sección</th><th>Acción</th></tr>
+ <?py for board in boards: ?>
+ <tr><td>/#{board['dir']}/</td><td>#{board['name']}</td><td>[<a href="#{cgi_url}manage/mod/#{board['dir']}">Navegar</a>]</td></tr>
+ <?py #endfor ?>
+</table>
+<?py elif mode == 2: ?>
+<table class="managertable">
+<tr>
+ <th>#</th>
+ <th>ID</th>
+ <th style="width:20%;">Asunto</th>
+ <th>Fecha</th>
+ <th style="width:80%;">Mensaje</th>
+ <th>Resp.</th>
+ <th>Acciones</th>
+</tr>
+<?py i = 1 ?>
+<?py for thread in threads: ?>
+<tr>
+ <td>#{i}</td>
+ <td>#{thread['id']}</td>
+ <td><a href="?thread=#{thread['id']}"><b>#{thread['subject']}</b></a></td>
+ <td class="date" data-unix="${thread['timestamp']}">#{thread['timestamp_formatted'][:21]}</td>
+ <td>${thread['message'][:200]}</td>
+ <td>#{thread['length']}</td>
+ <td style="white-space:nowrap;">
+ <a href="#{cgi_url}manage/lock/#{dir}/#{thread['id']}">L#{"-" if thread['locked'] == "1" else "+"}</a>
+ <a href="#{cgi_url}manage/permasage/#{dir}/#{thread['id']}">PS#{"-" if thread['locked'] == "2" else "+"}</a>
+ <a href="#{cgi_url}manage/move/#{dir}/#{thread['id']}">M</a>
+ <a href="#{cgi_url}manage/delete/#{dir}/#{thread['id']}">D</a>
+ <a href="#{cgi_url}manage/delete/#{dir}/#{thread['id']}?ban=true">&</a>
+ <a href="#{cgi_url}manage/ban/#{dir}/#{thread['id']}">B</a>
+ </td>
+</tr>
+<?py i += 1 ?>
+<?py #endfor ?>
+</table>
+<hr />
+[<a href="#{cgi_url}manage/mod" class="return">Volver</a>]
+<?py elif mode == 3: ?>
+<table class="managertable">
+<tr><th colspan="8" style="font-size:16pt;">Hilo: ${posts[0]['subject']} (#{posts[0]['length']})</th></tr>
+<tr><td colspan="8" style="font-size:14pt;text-align:center;"><a href="#{cgi_url}manage/lock/#{dir}/#{posts[0]['id']}">#{"Abrir hilo" if posts[0]['locked'] == "1" else "Cerrar hilo"}</a> /
+<a href="#{cgi_url}manage/permasage/#{dir}/#{posts[0]['id']}">#{"Quitar permasage" if posts[0]['locked'] == "2" else "Permasage"}</a> /
+<a href="#{cgi_url}manage/move/#{dir}/#{posts[0]['id']}">Mover hilo</a></td></tr>
+<tr>
+ <th>#</th>
+ <th>ID</th>
+ <th>Fecha</th>
+ <th>Nombre</th>
+ <th>Mensaje</th>
+ <th>Archivo</th>
+ <th>IP</th>
+ <th>Acción</th>
+</tr>
+<?py i = 1 ?>
+<?py for p in posts: ?>
+<tr>
+ <td>#{i}</td>
+ <td>#{p['id']}</td>
+ <td class="date" data-unix="${p['timestamp']}">${p['timestamp_formatted']}</td>
+ <td><span class="postername">${p['name']}</span></td>
+ <td>${p['message']}</td>
+ <td>
+ <?py if p['file']: ?><a href="/${dir}/src/#{p['file']}" target="_blank"><img src="/${dir}/mobile/${p['thumb']}" /></a><?py #endif ?>
+ </td>
+ <td><a href="#{cgi_url}manage/ipshow?ip=#{p['ip']}">#{p['ip']}</a></td>
+ <td style="white-space:nowrap;">
+ <?py if p['IS_DELETED'] == '0': ?>
+ <a href="#{cgi_url}manage/delete/#{dir}/#{p['id']}">Eliminar</a>
+ <a href="#{cgi_url}manage/delete/#{dir}/#{p['id']}?ban=true">&</a>
+ <a href="/cgi/manage/ban?ip=#{p['ip']}">Ban</a>
+ <?py elif p['IS_DELETED'] == '1': ?>
+ <a href="#{cgi_url}manage/recyclebin/0/restore/#{dir}/#{p['id']}">Recuperar</a>
+ <abbr title="Eliminado por usuario">[1]</abbr>
+ <?py elif p['IS_DELETED'] == '2': ?>
+ <a href="#{cgi_url}manage/recyclebin/0/restore/#{dir}/#{p['id']}">Recuperar</a>
+ <abbr title="Eliminado por staff">[2]</abbr>
+ <?py #endif ?>
+ </td>
+</tr>
+<?py i += 1 ?>
+<?py #endfor ?>
+</table>
+<hr />
+[<a href="#{cgi_url}manage/mod/#{dir}">Volver al panel</a>]
+<?py #endif ?>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?> \ No newline at end of file
diff --git a/cgi/templates/manage/move.html b/cgi/templates/manage/move.html
new file mode 100644
index 0000000..8fcc1e9
--- /dev/null
+++ b/cgi/templates/manage/move.html
@@ -0,0 +1,60 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<center>
+<div class="replymode">Mover hilo</div>
+<?py if oldboardid and oldthread: ?>
+<form action="#{cgi_url}manage/move/#{oldboardid}/#{oldthread}" method="post">
+<?py else: ?>
+<form action="#{cgi_url}manage/move" method="post">
+<?py #endif ?>
+<table>
+ <tr>
+ <td class="postblock">Board actual</td>
+ <td>
+ <?py if oldboardid and oldthread: ?>
+ <select name="oldboardid" style="width:100%;">
+ <?py for board in boards: ?>
+ <option value="#{board['dir']}"#{' selected="selected"' if oldboardid == board['dir'] else ''}>#{board['dir']} - #{board['name']}</option>
+ <?py #endfor ?>
+ </select>
+ <?py else: ?>
+ <select name="oldboardid" style="width:100%;">
+ <?py for board in boards: ?>
+ <option value="#{board['dir']}">#{board['dir']} - #{board['name']}</option>
+ <?py #endfor ?>
+ </select>
+ <?py #endif ?>
+ </td>
+ </tr>
+ <tr>
+ <td class="postblock">ID de hilo</td>
+ <td>
+ <?py if oldboardid and oldthread: ?>
+ <input type="text" name="oldthread" style="width:100%;" value="#{oldthread}" />
+ <?py else: ?>
+ <input type="text" name="oldthread" style="width:100%;" />
+ <?py #endif ?>
+ </td>
+ </tr>
+ <tr>
+ <td class="postblock">Mover a</td>
+ <td>
+ <select name="newboardid" style="width:100%;">
+ <?py for board in boards: ?>
+ <option value="#{board['dir']}">#{board['dir']} - #{board['name']}</option>
+ <?py #endfor ?>
+ </select>
+ </td>
+ </tr>
+ <tr>
+ <td class="postblock">Insertar mensaje</td>
+ <td>
+ <input type="checkbox" name="msg" value="1" />
+ </td>
+ </tr>
+ <tr><td colspan="2"><input type="submit" name="submit" style="width:100%;" value="Mover" /></td></tr>
+</table>
+</form>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?> \ No newline at end of file
diff --git a/cgi/templates/manage/quotes.html b/cgi/templates/manage/quotes.html
new file mode 100644
index 0000000..d30a403
--- /dev/null
+++ b/cgi/templates/manage/quotes.html
@@ -0,0 +1,12 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<center>
+ <div class="replymode">Quotes</div>
+ <p>Ingresa un mensaje a mostrar por cada linea:</p>
+ <form method="post" action="">
+ <textarea name="data" cols="80" rows="15" style="width:500px;height:250px;">${data}</textarea><br />
+ <input type="submit" name="save" style="width:500px;" value="Guardar" />
+ </form>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?>
diff --git a/cgi/templates/manage/rebuild.html b/cgi/templates/manage/rebuild.html
new file mode 100644
index 0000000..3afc057
--- /dev/null
+++ b/cgi/templates/manage/rebuild.html
@@ -0,0 +1,20 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<center>
+<div class="replymode">Reconstruir board</div>
+<table class="managertable">
+ <tr><th colspan="2">Sección</th><th colspan="2">Acción</th></tr>
+ <tr><td colspan="2"><b>Home</b></td><td colspan="2" style="text-align:center;">[<a href="#{cgi_url}manage/rebuild/!HOME">Reconstruir</a>]</td></tr>
+ <tr><td colspan="2"><b>Noticias</b></td><td colspan="2" style="text-align:center;">[<a href="#{cgi_url}manage/rebuild/!NEWS">Reconstruir</a>]</td></tr>
+ <tr><td colspan="2"><b>Índices de archivos</b></td><td colspan="2" style="text-align:center;">[<a href="#{cgi_url}manage/rebuild/!KAKO">Reconstruir</a>]</td></tr>
+ <tr><td colspan="2"><b>.htaccess</b></td><td colspan="2" style="text-align:center;">[<a href="#{cgi_url}manage/rebuild/!HTACCESS">Reconstruir</a>]</td></tr>
+ <?py for board in boards: ?>
+ <tr><td>/#{board['dir']}/</td><td>#{board['name']}</td><td>[<a href="#{cgi_url}manage/rebuild/#{board['dir']}">Reconstruir frontales</a>]</td><td>[<a href="#{cgi_url}manage/rebuild/#{board['dir']}?everything=1">Reconstruir todo</a>]</td></tr>
+ <?py #endfor ?>
+ <tr><td colspan="4" align="center"><form action="#{cgi_url}manage/rebuild/!ALL" method="get"><input type="submit" style="width:100%" value="Reconstruir todos (frontales)" /></form></td></tr>
+ <tr><td colspan="4" align="center"><form action="#{cgi_url}manage/rebuild/!BBS" method="get"><input type="submit" style="width:100%" value="Reconstruir todos (BBS)" /></form></td></tr>
+ <tr><td colspan="4" align="center"><form action="#{cgi_url}manage/rebuild/!IB" method="get"><input type="submit" style="width:100%" value="Reconstruir todos (IB)" /></form></td></tr>
+</table>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?>
diff --git a/cgi/templates/manage/recent_images.html b/cgi/templates/manage/recent_images.html
new file mode 100644
index 0000000..39f919c
--- /dev/null
+++ b/cgi/templates/manage/recent_images.html
@@ -0,0 +1,24 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<style>.imgs{font-size:0;}.imgs img{vertical-align:top;margin:2px;height:150px;width:auto;}</style>
+<center>
+<div class="replymode">Imágenes recientes</div>
+<form action="#{cgi_url}manage/recent_images" name="recent_images" method="post">
+ <table>
+ <tr><td class="postblock">Número a mostrar</td><td><input type="text" name="images" size="4" /></td></tr>
+ <tr><td colspan="2"><input type="submit" style="width:100%;" value="Enviar" /></td></tr>
+ </table>
+</form>
+<hr />
+<div class="imgs">
+<?py for post in posts: ?>
+ <?py if post['parentid'] != '0': ?>
+ <a href="/#{post['dir']}/res/#{post['parentid']}.html##{post['id']}"><img src="#{boards_url}#{post['dir']}/thumb/#{post['thumb']}" /></a>
+ <?py else: ?>
+ <a href="/#{post['dir']}/res/#{post['id']}.html##{post['id']}"><img src="#{boards_url}#{post['dir']}/thumb/#{post['thumb']}" /></a>
+ <?py #endif ?>
+<?py #endfor ?>
+</div>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?> \ No newline at end of file
diff --git a/cgi/templates/manage/recyclebin.html b/cgi/templates/manage/recyclebin.html
new file mode 100644
index 0000000..b413c9c
--- /dev/null
+++ b/cgi/templates/manage/recyclebin.html
@@ -0,0 +1,72 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<?py from tenjin.helpers.html import * ?>
+<center>
+<div class="replymode">Papelera de Reciclaje</div>
+<form name="boardForm" method="get" action="#{cgi_url}manage/recyclebin/0">
+<table>
+<tr>
+ <td class="postblock">Eliminado por</td>
+ <td>
+ <input type="radio" id="type1" name="type" value="1"#{checked(type == 1)} /><label for="type1">Usuario</label>
+ <input type="radio" id="type2" name="type" value="2"#{checked(type == 2)} /><label for="type2">Staff</label>
+ <input type="radio" id="type0" name="type" value="0"#{checked(type == 0)} /><label for="type0">Ambos</label>
+ </td>
+</tr>
+<tr>
+ <td class="postblock">Board</td><td>
+ <select name="board" style="width:100%;">
+ <option value="all">Todos los boards</option>
+<?py for board in boards: ?>
+ <option value="#{board['dir']}"#{selected(board['checked'])}>#{board['dir']} - ${board['name']}</option>
+<?py #endfor ?>
+ </select>
+ </td>
+</tr>
+<tr><td colspan="2"><input type="submit" style="width:100%;" value="Mostrar" /></td></tr>
+</table>
+</form>
+<hr />
+<?py if message: ?>
+${message}
+<hr />
+<?py #endif ?>
+<?py if not skip: ?>
+<form name="deleteForm" method="post" action="#{cgi_url}manage/recyclebin/#{currentpage}">
+ <?py if curboard: ?>
+ <input type="hidden" name="board" value="#{curboard}" />
+ <?py #endif ?>
+ <table class="managertable">
+ <tr>
+ <th></th>
+ <th></th>
+ <th>ID</th>
+ <th>Timestamp</th>
+ <th>Board</th>
+ <th>Tipo</th>
+ <th>IP</th>
+ <th>Mensaje</th>
+ </tr>
+ <?py for post in posts: ?>
+ <tr>
+ <td><a href="#{cgi_url}manage/recyclebin/#{currentpage}/delete/#{post['dir']}/#{post['id']}">X</a><br /><a href="#{cgi_url}manage/recyclebin/#{currentpage}/restore/#{post['dir']}/#{post['id']}">R</a></td>
+ <td><input type="checkbox" name="!i#{post['dir']}/#{post['id']}" id="#{post['dir']}#{post['id']}" value="1" /><label for="#{post['dir']}#{post['id']}"></label></td>
+ <td>#{post['id']}</td>
+ <td class="date" data-unix="${post['timestamp']}">${post['timestamp_formatted']}</td>
+ <td>${post['dir']}</td>
+ <td>${post['IS_DELETED']}</td>
+ <td>${post['ip']}</td>
+ <td>#{post['message']}</td>
+ </tr>
+ <?py #endfor ?>
+ <tr><td colspan="8" align="center"><input name="deleteall" type="submit" value="Eliminar seleccionados" /></td></tr>
+ </table>
+</form>
+<hr />
+<div style="font-size:larger">#{navigator}</div>
+<?py else: ?>
+ No hay posts.
+<?py #endif ?>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?>
diff --git a/cgi/templates/manage/reports.html b/cgi/templates/manage/reports.html
new file mode 100644
index 0000000..f47ec38
--- /dev/null
+++ b/cgi/templates/manage/reports.html
@@ -0,0 +1,58 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<?py from tenjin.helpers.html import * ?>
+<center>
+<div class="replymode">Reportes</div>
+<?py if message: ?>
+${message}
+<?py #endif ?>
+<form name="boardForm" method="get" action="#{cgi_url}manage/reports/0">
+<table>
+ <tr>
+ <td class="postblock">Board</td>
+ <td>
+ <select name="board">
+ <option value="all">Todos los boards</option>
+<?py for board in boards: ?>
+ <option value="#{board['dir']}"#{selected(board['checked'])}>#{board['dir']} - #{board['name']}</option>
+<?py #endfor ?>
+ </select>
+ <td><input type="submit" value="Mostrar" /></td>
+ </td></tr>
+</table>
+</form>
+
+<form name="ignoreForm" method="post" action="#{cgi_url}manage/reports/#{currentpage}">
+<?py if curboard: ?>
+<input type="hidden" name="board" value="#{board}" />
+<?py #endif ?>
+<hr />
+<table class="managertable">
+<tr>
+ <th></th>
+ <th></th>
+ <th>Fecha</th>
+ <th>Post</th>
+ <th>IP Post</th>
+ <th>Raz&oacute;n</th>
+ <th>IP Denuncia</th>
+</tr>
+<?py for report in reports: ?>
+<tr>
+ <td> <a href="#{cgi_url}manage/reports/#{currentpage}/ignore/#{report['id']}">X</a> </td>
+ <td><input type="checkbox" name="i#{report['id']}" id="i#{report['id']}" value="1" /><label for="i#{report['id']}"></label></td>
+ <td class="date" data-unix="${report['timestamp']}">${report['timestamp_formatted']}</td>
+ <td><a href="#{report['link']}">${report['link']}</a></td>
+ <td><a href="#{cgi_url}manage/ipshow?ip=${report['ip']}">${report['ip']}</a></td>
+ <td>${report['reason']}</td>
+ <td><a href="#{cgi_url}manage/ipshow?ip=${report['reporterip']}">${report['reporterip']}</a></td>
+</tr>
+<?py #endfor ?>
+<tr>
+ <td colspan="8" style="text-align:center;"><input name="ignore" type="submit" value="Ignorar seleccionados" /></td>
+</tr>
+</table>
+</form>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?>
diff --git a/cgi/templates/manage/search.html b/cgi/templates/manage/search.html
new file mode 100644
index 0000000..6c2ec6f
--- /dev/null
+++ b/cgi/templates/manage/search.html
@@ -0,0 +1,27 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<center>
+<div class="replymode">Registro de búsqueda</div>
+<table class="managertable">
+ <tr>
+ <th>ID</th>
+ <th>Fecha</th>
+ <th>Búsqueda</th>
+ <th>En</th>
+ <th>Resultados</th>
+ <th>Por</th>
+ </tr>
+<?py for log in search: ?>
+ <tr>
+ <td>${log['id']}</td>
+ <td class="date" data-unix="${log['timestamp']}">${log['timestamp_formatted']}</td>
+ <td>${log['keyword']}</td>
+ <td>${"[A] " if log['archive'] else ""}${"Global" if log["ita"] == "" else log["ita"]}</td>
+ <td>${log['res']}</td>
+ <td><a href="#{cgi_url}manage/ipshow?ip=${log['ip']}">${log['ip']}</a></td>
+ </tr>
+<?py #endfor ?>
+</table>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?> \ No newline at end of file
diff --git a/cgi/templates/manage/staff.html b/cgi/templates/manage/staff.html
new file mode 100644
index 0000000..787a843
--- /dev/null
+++ b/cgi/templates/manage/staff.html
@@ -0,0 +1,63 @@
+<?py include('templates/base_top.html') ?>
+<?py include('templates/manage/menu.html') ?>
+<?py from tenjin.helpers.html import * ?>
+<center>
+<div class="replymode">Staff</div>
+<?py if mode == 0: ?>
+ <table class="managertable">
+ <tr>
+ <th>ID</th>
+ <th>Nombre</th>
+ <th>Nivel</th>
+ <th>Última actividad</th>
+ <th>Acciones</th>
+ </tr>
+ <?py for member in staff: ?>
+ <tr>
+ <td>${member['id']}</td>
+ <td><b>${member['username']}</b></td>
+ <td>${member['rights']}</td>
+ <td class="date" data-unix="${member['lastactivestamp']}">${member['lastactive']}</td>
+ <td>
+ [<a href="#{cgi_url}manage/staff/edit/#{member['id']}">Editar</a>]
+ [<a href="#{cgi_url}manage/staff/delete/#{member['id']}">Eliminar</a>]
+ </td>
+ </tr>
+ <?py #endfor ?>
+ <tr>
+ <td colspan="5"><form action="#{cgi_url}manage/staff/add" method="get"><input type="submit" style="width:100%;" value="Agregar miembro" /></form></td>
+ </tr>
+ </table>
+<?py elif mode == 1: ?>
+<form action="#{cgi_url}manage/staff/#{action}" method="post">
+<table>
+ <tr>
+ <td class="postblock">Nombre</td>
+ <td><input type="text" name="username" value="${member_username}" style="width:100%;" /></td>
+ </tr>
+ <?py if not member: ?>
+ <tr>
+ <td class="postblock">Contraseña</td>
+ <td><input type="password" name="password" style="width:100%;"/></td>
+ </tr>
+ <?py #endif ?>
+ <tr>
+ <td class="postblock">Nivel</td>
+ <td>
+ <select name="rights" style="width:100%;">
+ <option value="3"#{selected(member_rights == '3')}>Moderador</option>
+ <option value="2"#{selected(member_rights == '2')}>Developer</option>
+ <option value="1"#{selected(member_rights == '1')}>Administrador</option>
+ <option value="0"#{selected(member_rights == '0')}>Super-Administrador</option>
+ </select>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="2"><input type="submit" name="submit" style="width:100%;" value="${submit}"/></td>
+ </tr>
+</table>
+</form>
+<?py #endif ?>
+</center>
+<hr />
+<?py include('templates/base_bottom.html') ?> \ No newline at end of file
diff --git a/cgi/templates/mobile/base_top.html b/cgi/templates/mobile/base_top.html
new file mode 100644
index 0000000..6a6c5bd
--- /dev/null
+++ b/cgi/templates/mobile/base_top.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+<?py if (replythread and threads) or board: ?>
+ <title>#{board_name}@Bienvenido a Internet Móvil</title>
+<?py else: ?>
+ <title>Bienvenido a Internet Móvil</title>
+<?py #endif ?>
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <link rel="shortcut icon" href="#{static_url}img/favicon.ico" />
+ <link rel="stylesheet" type="text/css" href="#{static_url}css/mobile.css?v=8" />
+ <script type="text/javascript" src="#{static_url}js/mobile.js?v=9"></script>
+</head>
diff --git a/cgi/templates/mobile/board.html b/cgi/templates/mobile/board.html
new file mode 100644
index 0000000..70b8461
--- /dev/null
+++ b/cgi/templates/mobile/board.html
@@ -0,0 +1,55 @@
+<?py include('templates/mobile/base_top.html') ?>
+<body class="img"><a name="top"></a>
+<div class="nav"><div><a href="//m.bienvenidoainternet.org">Home</a><a href="#{cgi_url}mobile/#{board}/">Volver</a><a href="#form">&#9660;</a></div></div>
+<?py for thread in threads: ?>
+<div id="thread">
+<?py for post in thread['posts']: ?>
+ <?py if post['IS_DELETED'] == "1": ?>
+ <div class="pst"><h3 class="del"><a name="#{post['id']}"></a>No.#{post['id']} eliminado por el usuario.</h3></div>
+ <?py elif post['IS_DELETED'] == "2": ?>
+ <div class="pst"><h3 class="del"><a name="#{post['id']}"></a>No.#{post['id']} eliminado por miembro del staff.</h3></div>
+ <?py else: ?>
+ <?py if post['parentid'] == "0": ?>
+ <div class="first"><h1>#{post["subject"]} <span>(#{thread['length']})</span></h1>
+ <?py else: ?>
+ <div class="pst">
+ <?py if post['subject']: ?>
+ <h2>#{post["subject"]}</h2>
+ <?py #endif ?>
+ <?py #endif ?><h3><a href="#" class="num" name="#{post['id']}">#{post['id']}</a>#{post['name']} #{post['tripcode']} #{post['timestamp_formatted']}</h3>
+ <?py if post['file']: ?><a href="/#{board}/src/#{post['file']}" target="_blank" class="thm"><img src="/#{board}/mobile/#{post['thumb']}" /><br />#{int(post['file_size'])//1024}KB #{post['file'].split(".")[1].upper()}</a><?py #endif ?>
+ <div class="msg">#{post['message']}</div></div>
+ <?py #endif ?>
+<?py #endfor ?>
+<?py if threads[0]['posts'][0]['locked'] != "1": ?>
+<a href="./#{thread['id']}" id="n">Recargar</a><span id="n2"></span>
+<?py #endif ?>
+<div class="nav"><div><a href="//m.bienvenidoainternet.org">Home</a><a href="#{cgi_url}mobile/#{board}/">Volver</a><a href="#top">&#9650;</a></div></div>
+<?py if threads[0]['posts'][0]['locked'] == "1": ?>
+ <div class="warn red" style="text-align:center;">El hilo ha sido cerrado. Ya no se puede postear en &eacute;l.</div>
+<?py else: ?>
+<form name="postform" id="postform" action="/cgi/post" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="board" value="#{board}" /><input type="hidden" name="parent" value="#{replythread}" /><input type="hidden" name="mobile" value="true" /><input type="hidden" name="password" value="" />
+ <div style="display:none;"><input type="text" name="name" /><input type="text" name="email" /></div>
+ <?py if not disable_subject: ?>
+ <input class="fld" type="text" name="subject" placeholder="Asunto (opcional)" />
+ <?py #endif ?>
+ <?py if not disable_name: ?>
+ <input class="fld" type="text" name="fielda" placeholder="Nombre (opcional)" />
+ <?py #endif ?>
+ <input class="fld" type="text" name="fieldb" placeholder="E-mail (opcional)" />
+ <textarea name="message" rows="6"></textarea>
+<?py if allow_image_replies: ?>
+ <div class="file"><input type="file" name="file" class="fld" />
+ <?py if allow_spoilers: ?>
+ <label class="fld"><input type="checkbox" name="spoil" /> Spoiler</label>
+ <?py #endif ?></div>
+<?py #endif ?>
+ <input id="post" type="submit" value="Responder" />
+</form>
+<?py #endif ?>
+</div>
+<?py #endfor ?>
+<a name="form"></a>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/mobile/error.html b/cgi/templates/mobile/error.html
new file mode 100644
index 0000000..00ae4f4
--- /dev/null
+++ b/cgi/templates/mobile/error.html
@@ -0,0 +1,6 @@
+<?py include('templates/mobile/base_top.html') ?>
+<body class="img">
+<div class="top"><a href="//m.bienvenidoainternet.org"><img src="#{static_url}css/img/0back.png" /><br />Home</a>Error</div><br />
+<hr size="1"><br /><br /><div style="color:red;font-size:x-large;font-weight:bold;text-align:center;">#{error}</div><br /><br /><hr size="1">
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/mobile/latest.html b/cgi/templates/mobile/latest.html
new file mode 100644
index 0000000..615b21c
--- /dev/null
+++ b/cgi/templates/mobile/latest.html
@@ -0,0 +1,14 @@
+<?py include('templates/mobile/base_top.html') ?>
+<body class="txt">
+<div class="top">
+ <a href="/movil.html"><img src="#{static_url}css/img/0info.png" /><br />Info</a>
+ Bienvenido a Internet Móvil
+</div>
+<div class="bar"><a href="//m.bienvenidoainternet.org">Secciones</a><a href="/cgi/mobilehome" class="sel">Hilos activos</a><a href="/cgi/mobilenewest">Nuevos hilos</a></div>
+<div class="list">
+ <?py for thread in latest_age: ?>
+ <a href="/cgi/mobileread${thread['url']}">#{thread['content']}<div>${thread['board_fulln']} <span>R:<span>#{int(thread['length'])-1}</span></span></div></a>
+ <?py #endfor ?>
+</div>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/mobile/newest.html b/cgi/templates/mobile/newest.html
new file mode 100644
index 0000000..37fd67f
--- /dev/null
+++ b/cgi/templates/mobile/newest.html
@@ -0,0 +1,14 @@
+<?py include('templates/mobile/base_top.html') ?>
+<body class="txt">
+<div class="top">
+ <a href="/movil.html"><img src="#{static_url}css/img/0info.png" /><br />Info</a>
+ Bienvenido a Internet Móvil
+</div>
+<div class="bar"><a href="//m.bienvenidoainternet.org">Secciones</a><a href="/cgi/mobilehome">Hilos activos</a><a href="/cgi/mobilenewest" class="sel">Nuevos hilos</a></div>
+<div class="list">
+ <?py for thread in newthreads: ?>
+ <a href="/cgi/mobileread${thread['url']}">#{thread['content']}<div>${thread['board_fulln']}</div></a>
+ <?py #endfor ?>
+</div>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/mobile/threadlist.html b/cgi/templates/mobile/threadlist.html
new file mode 100644
index 0000000..edb81eb
--- /dev/null
+++ b/cgi/templates/mobile/threadlist.html
@@ -0,0 +1,43 @@
+<?py include('templates/mobile/base_top.html') ?>
+<body class="img">
+<div class="top">
+ <a href="//m.bienvenidoainternet.org"><img src="#{static_url}css/img/0back.png" /><br />Home</a>
+ #{board_name}
+</div>
+<?py if mode == 1: ?>
+ <div class="bar"><a href="#{cgi_url}mobile/#{board}" class="sel">Portada</a><a href="#{cgi_url}mobilelist/#{board}">Lista</a><a href="#{cgi_url}mobilecat/#{board}">Cat&aacute;logo</a><a href="#{cgi_url}mobilenew/#{board}">Nuevo hilo</a></div>
+ <?py for thread in more_threads: ?>
+ <div class="prev">
+ <a href="#{cgi_url}mobileread/#{board}/#{thread['id']}"><img class="thm" src="/#{board}/mobile/#{thread['thumb']}" />
+ <b>#{thread["subject"]}</b> (R:#{int(thread["length"])-1})</a>
+ <h3>#{thread['name']} #{thread['tripcode']} #{thread['timestamp_formatted']}</h3>
+ #{thread['message']}#{" [...]" if thread['shortened'] else ""}
+ <?py if thread['lastreply']: ?>
+ <div class="pst"><h3>#{thread['lastreply']['name']} #{thread['lastreply']['tripcode']} #{thread['lastreply']['timestamp_formatted']}</h3>
+ #{thread['lastreply']['message']}#{" [...]" if thread['lastreply']['shortened'] else ""}</div>
+ <?py #endif ?>
+ </div>
+ <?py #endfor ?>
+<?py elif mode == 2: ?>
+ <div class="bar"><a href="#{cgi_url}mobile/#{board}">Portada</a><a href="#{cgi_url}mobilelist/#{board}" class="sel">Lista</a><a href="#{cgi_url}mobilecat/#{board}">Cat&aacute;logo</a><a href="#{cgi_url}mobilenew/#{board}">Nuevo hilo</a></div>
+ <div class="search"><input id="search" placeholder="Buscar en asuntos" style="padding:7px;" type="text"></div>
+ <div class="ord"><span>Orden:</span><a data-sort="0" class="sel" href="#">Normal</a><a data-sort="1" href="#">Nuevo</a><a data-sort="2" href="#">Viejo</a><a data-sort="3" href="#">Más</a><a data-sort="4" href="#">Menos</a></div>
+ <div id="to_sort" class="list">
+ <?py i = 1 ?>
+ <?py for thread in more_threads: ?>
+ <a data-num="${i}" data-res="#{thread['length']}" data-id="#{thread['id']}" href="#{cgi_url}mobileread/#{board}/#{thread['id']}"><strong>#{thread["subject"]}</strong>: #{thread['message']}#{" [...]" if thread['shortened'] else ""}<br />
+ <span class="info"><span>&Uacute;ltima: #{thread['lastreply']['timestamp_formatted'] if thread['lastreply'] else thread['timestamp_formatted']}</span> Respuestas: <b>#{int(thread["length"])-1}</b></span></a>
+ <?py i += 1 ?>
+ <?py #endfor ?>
+ </div>
+<?py else: ?>
+ <div class="bar"><a href="#{cgi_url}mobile/#{board}">Portada</a><a href="#{cgi_url}mobilelist/#{board}">Lista</a><a href="#{cgi_url}mobilecat/#{board}" class="sel">Cat&aacute;logo</a><a href="#{cgi_url}mobilenew/#{board}">Nuevo hilo</a></div>
+ <div class="search"><input id="catsearch" placeholder="Buscar en catálogo" style="padding:7px;" type="text"></div>
+ <div class="ord"><span>Orden:</span><a data-sort="0" class="sel" href="#">Normal</a><a data-sort="1" href="#">Nuevo</a><a data-sort="2" href="#">Viejo</a><a data-sort="3" href="#">Más</a><a data-sort="4" href="#">Menos</a></div>
+ <div id="to_sort" style="text-align:center;margin-top:0.5em;">
+ <?py i = 1 ?>
+ <?py for thread in more_threads: ?><a data-num="${i}" data-res="#{thread['length']}" data-id="#{thread['id']}" class="cat" href="#{cgi_url}mobileread/#{board}/#{thread['id']}"><img src="/#{board}/mobile/#{thread['thumb']}" /><br />(#{int(thread["length"])-1}R) <strong>#{thread["subject"]}</strong>: #{thread['message']}#{" [...]" if thread['shortened'] else ""}</a><?py i += 1 ?><?py #endfor ?>
+ </div>
+<?py #endif ?>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/mobile/txt_newthread.html b/cgi/templates/mobile/txt_newthread.html
new file mode 100644
index 0000000..b19d2fa
--- /dev/null
+++ b/cgi/templates/mobile/txt_newthread.html
@@ -0,0 +1,35 @@
+<?py include('templates/mobile/base_top.html') ?>
+<body class="#{"txt" if board_type == '1' else "img"}">
+<div class="top">
+ <a href="//m.bienvenidoainternet.org"><img src="#{static_url}css/img/0back.png" /><br />Home</a>
+ #{board_name}
+</div>
+<?py if board_type == '1': ?>
+<div class="bar"><a href="#{cgi_url}mobile/#{board}">Portada</a><a href="#{cgi_url}mobilelist/#{board}">Todos los hilos</a><a href="#{cgi_url}mobilenew/#{board}" class="sel">Nuevo hilo</a></div>
+<?py else: ?>
+<div class="bar"><a href="#{cgi_url}mobile/#{board}">Portada</a><a href="#{cgi_url}mobilelist/#{board}">Lista</a><a href="#{cgi_url}mobilecat/#{board}">Cat&aacute;logo</a><a href="#{cgi_url}mobilenew/#{board}" class="sel">Nuevo hilo</a></div>
+<?py #endif ?>
+<form name="postform" id="postform" action="/cgi/post" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="board" value="#{board}" /> <input type="hidden" name="mobile" value="true" /><input type="hidden" name="password" value="" />
+ <div style="display:none;"><input type="text" name="name" maxlength="50" /><input type="text" name="email" maxlength="50" /></div>
+ <?py if not disable_subject: ?>
+ <input class="fld imp" type="text" name="subject" placeholder="Asunto#{" (opcional)" if board_type == '0' else ""}" maxlength="100" />
+ <?py #endif ?>
+ <?py if not disable_name: ?>
+ <input class="fld" type="text" name="fielda" placeholder="Nombre (opcional)" maxlength="50" />
+ <?py #endif ?>
+ <input class="fld" type="text" name="fieldb" placeholder="E-mail (opcional)" maxlength="50" />
+ <textarea name="message" rows="#{"8" if board_type == '1' else "6"}"></textarea>
+<?py if allow_images: ?>
+ <div class="file"><input type="file" name="file" class="fld" />
+ <?py if allow_spoilers: ?>
+ <label class="fld"><input type="checkbox" name="spoil" /> Spoiler</label>
+ <?py #endif ?></div>
+<?py #endif ?>
+ <input id="post" type="submit" value="Crear nuevo hilo" />
+</form>
+<?py if allow_images: ?>
+ <div class="rules">Formatos permitidos: #{', '.join(supported_filetypes).upper()}<br />Tamaño máximo: #{maxsize} KB</div>
+<?py #endif ?>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/mobile/txt_thread.html b/cgi/templates/mobile/txt_thread.html
new file mode 100644
index 0000000..8a19a94
--- /dev/null
+++ b/cgi/templates/mobile/txt_thread.html
@@ -0,0 +1,74 @@
+<?py include('templates/mobile/base_top.html') ?>
+<body class="txt">
+<a name="top"></a>
+<?py for thread in threads: ?>
+<div class="nav"><div><a href="//m.bienvenidoainternet.org">Home</a><a href="#{cgi_url}mobile/#{board}/">Volver</a><a href="#form">&#9660;</a></div></div>
+<div id="nav2">
+ <a href="#{cgi_url}mobileread/#{board}/#{thread['timestamp']}">Ver hilo completo</a>
+<?py if thread['length'] > 51: ?>
+ <a href="#{cgi_url}mobileread/#{board}/#{thread['timestamp']}/l25" rel="nofollow">Últimos 25</a>
+<?py #endif ?>
+<?py if thread['length'] > 50: ?>
+ <a href="#{cgi_url}mobileread/#{board}/#{thread['timestamp']}/-50" rel="nofollow">Primeros 50</a>
+<?py #endif ?>
+<?py r = range(thread['length'] / 50) ?>
+<?py for i in r[:-1]: ?>
+ <a href="#{cgi_url}mobileread/#{board}/#{thread['timestamp']}/#{(i+1)*50+1}-#{(i+2)*50}" rel="nofollow">#{(i+1)*50+1}-#{(i+2)*50}</a>
+<?py #endfor ?>
+<?py if r: ?>
+ <a href="#{cgi_url}mobileread/#{board}/#{thread['timestamp']}/#{(r[-1]+1)*50+1}-#{(r[-1]+2)*50}" rel="nofollow">#{(r[-1]+1)*50+1}-</a>
+<?py #endif ?>
+</div>
+<?py if thread['length'] > 1000: ?>
+ <div class="stop red">■ El hilo superó los 1000 mensajes y ha sido cerrado.</div>
+<?py elif thread['length'] > 950: ?>
+ <div class="warn red">■ El hilo ha recibido más de 950 mensajes. Límite: 1000</div>
+<?py elif thread['length'] > 900: ?>
+ <div class="warn yellow">■ El hilo ha recibido más de 900 mensajes. Límite: 1000</div>
+<?py #endif ?>
+<div id="thread">
+<h1>#{thread['subject']} <span>(#{thread['length']})</span></h1>
+<?py for post in thread['posts']: ?>
+<?py if post['IS_DELETED'] == '1': ?>
+<div class="pst"><h3 class="del"><a href="#" class="num">#{str(post['num']).zfill(4)}</a> Eliminado por el usuario.</h3></div>
+<?py elif post['IS_DELETED'] == '2': ?>
+<div class="pst"><h3 class="del"><a href="#" class="num">#{str(post['num']).zfill(4)}</a> Eliminado por miembro del staff.</h3></div>
+<?py else: ?>
+<div id="p#{post['id']}" class="pst">
+ <h3><a href="#" class="num">#{str(post['num']).zfill(4)}</a> #{post['name']} #{post['tripcode']}</h3>
+ <?py if post['file']: ?><a href="/#{board}/src/#{post['file']}" target="_blank" class="thm"><img src="/#{board}/mobile/#{post['thumb']}" /><br />#{int(post['file_size'])//1024}KB #{post['file'].split(".")[1].upper()}</a><?py #endif ?>
+ <div class="msg">#{post['message']}</div>
+ <h4>#{post['timestamp_formatted']}</h4>
+</div>
+<?py #endif ?>
+<?py #endfor ?>
+<?py if thread['locked'] != '1': ?>
+<a href="#{cgi_url}mobileread/#{board}/#{thread['timestamp']}/#{thread['length']}-n" id="n">Ver nuevos posts</a><span id="n2"></span>
+<?py #endif ?>
+<div class="nav">
+ <div><a href="//m.bienvenidoainternet.org">Home</a><a href="#{cgi_url}mobile/#{board}/">Volver</a><a href="#top">&#9650;</a></div>
+ <?py if nextrange: ?>
+ <div><a href="#{cgi_url}mobileread/#{board}/#{thread['timestamp']}">Hilo completo</a><a href="#{cgi_url}mobileread/#{board}/#{thread['timestamp']}/-50">Primeros 50</a><a href="#{cgi_url}mobileread/#{board}/#{thread['timestamp']}/l10">Últimos 25</a></div>
+ <?py #endif ?>
+</div>
+<?py if thread['locked'] != '1': ?>
+ <form name="postform" id="postform" action="/cgi/post" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="board" value="#{board}" /><input type="hidden" name="parent" value="#{thread['id']}" /><input type="hidden" name="mobile" value="true" /><input type="hidden" name="password" value="" />
+ <div style="display:none"><input type="text" name="name" /><input type="text" name="email" /></div>
+ <input class="fld" type="text" name="fielda" placeholder="Nombre (opcional)" />
+ <input class="fld" type="text" name="fieldb" placeholder="E-mail (opcional)" />
+ <textarea name="message" rows="6"></textarea>
+<?py if allow_image_replies: ?>
+ <div class="file"><input type="file" name="file" class="fld" />
+ <?py if allow_spoilers: ?>
+ <label class="fld"><input type="checkbox" name="spoil" /> Spoiler</label>
+ <?py #endif ?></div>
+<?py #endif ?>
+ <input id="post" type="submit" value="Responder" />
+ </form>
+<?py #endif ?>
+</div>
+<a name="form"></a>
+<?py #endfor ?>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/mobile/txt_threadlist.html b/cgi/templates/mobile/txt_threadlist.html
new file mode 100644
index 0000000..5e3d133
--- /dev/null
+++ b/cgi/templates/mobile/txt_threadlist.html
@@ -0,0 +1,26 @@
+<?py include('templates/mobile/base_top.html') ?>
+<body class="txt">
+<div class="top">
+ <a href="//m.bienvenidoainternet.org"><img src="#{static_url}css/img/0back.png" /><br />Home</a>
+ #{board_name}
+</div>
+<?py if mode == 1: ?>
+<div class="bar"><a href="#{cgi_url}mobile/#{board}" class="sel">Portada</a><a href="#{cgi_url}mobilelist/#{board}">Todos los hilos</a><a href="#{cgi_url}mobilenew/#{board}">Nuevo hilo</a></div>
+<?py else: ?>
+<div class="bar"><a href="#{cgi_url}mobile/#{board}">Portada</a><a href="#{cgi_url}mobilelist/#{board}" class="sel">Todos los hilos</a><a href="#{cgi_url}mobilenew/#{board}">Nuevo hilo</a></div>
+<div class="search"><input id="search" placeholder="Buscar en asuntos" type="text"></div>
+<div class="ord"><span>Orden:</span><a data-sort="0" class="sel" href="#">Normal</a><a data-sort="1" href="#">Nuevo</a><a data-sort="2" href="#">Viejo</a><a data-sort="3" href="#">Más</a><a data-sort="4" href="#">Menos</a></div>
+<?py #endif ?>
+<div id="to_sort" class="list">
+ <?py i = 1 ?>
+ <?py for thread in more_threads: ?>
+ <?py if int(thread["length"]) > 10: ?>
+ <a data-num="${i}" data-res="${thread['length']}" data-id="${thread['id']}" href="#{cgi_url}mobileread/#{board}/#{thread['timestamp']}/l10">#{thread['subject']}<br /><span class="info"><span>Última: #{timestamps[i-1][1]}</span> Respuestas: <b>#{thread['length']}</b></span></a>
+ <?py else: ?>
+ <a data-num="${i}" data-res="#{thread['length']}" data-id="#{thread['id']}" href="#{cgi_url}mobileread/#{board}/#{thread['timestamp']}/l10">#{thread['subject']}<br /><span class="info"><span>Última: #{timestamps[i-1][1]}</span> Respuestas: <b>#{thread['length']}</b></span></a>
+ <?py #endif ?>
+ <?py i += 1 ?>
+ <?py #endfor ?>
+</div>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/mod.html b/cgi/templates/mod.html
new file mode 100644
index 0000000..21a35f6
--- /dev/null
+++ b/cgi/templates/mod.html
@@ -0,0 +1,86 @@
+<!-- MOD/S3M/XM module player for Web Audio (c) 2012-2015 Firehawk/TDA (firehawk@haxor.fi) -->
+<!-- Modificado para funcionar con Bienvenido a Internet BBS/IB -->
+<html>
+ <head>
+ <title>MOD/S3M/XM module player for Web Audio</title>
+ <meta name="description" content="A MOD/S3M/XM module player in Javascript using the Web Audio API.">
+ <link rel="stylesheet" href="/firehawk/style.css" type="text/css" media="screen" />
+ <script type="text/javascript" src="/firehawk/jquery-2.1.1.js"></script>
+ <script type="text/javascript" src="/firehawk/utils.js"></script>
+ <script type="text/javascript" src="/firehawk/player.js"></script>
+ <script type="text/javascript" src="/firehawk/pt.js"></script>
+ <script type="text/javascript" src="/firehawk/st3.js"></script>
+ <script type="text/javascript" src="/firehawk/ft2.js"></script>
+ <script type="text/javascript" src="/firehawk/ui.js"></script>
+ </head>
+ <body data-module="/#{board}/src/#{modfile}">
+ <div id="outercontainer">
+ <div id="headercontainer">
+ <div style="margin-left:8px;float:left">MOD/S3M/XM module player for Web Audio</div>
+ <div style="margin-right:8px;float:right">(c) 2012-2015 Firehawk/<a class="tdalink" href="http://tda.haxor.fi/" target="_blank">TDA</a></div>
+ <div style="clear:both;"></div>
+ </div>
+ <div id="innercontainer">
+ <div id="modsamples">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+</div>
+ <div style="position:relative;top:8px;margin-bottom:8px;">
+ <span id="modtitle">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
+ <span id="modinfo">('&nbsp;&nbsp;&nbsp;&nbsp;')</span>
+ <span id="modtimer"></span>
+ <br/><br/>
+ <a href="#" id="go_back">[&lt;&lt;]</a>
+ <a href="#" id="play">[reproducir]</a>
+ <a href="#" id="pause">[pausa]</a>
+ <a href="#" id="go_fwd">[&gt;&gt;]</a>
+ <span style="white-space:pre;"> </span>
+ <a href="#" title="Repeat song" id="modrepeat">[rept]</a>
+ <a class="down" title="Stereo separation" href="#" id="modpaula">[)oo(]</a>
+ <a class="down" title="Visualization type" href="#" id="modvis">[trks]</a>
+ <a title="Amiga A500 lowpass filter" href="#" id="modamiga">[filt]</a>
+ </div>
+ <div id="modchannels"><div id="even-channels"></div><div id="odd-channels"></div></div>
+ <div id="modpattern"></div>
+ <div style="clear:both"></div>
+ <div id="infotext">
+ Esta es una instancia local del reproductor de MODs por Firehawk - <a href="https://twitter.com/janihalme" style="color:#cce;">Twitter</a> / <a href="mailto:firehawk@haxor.fi" style="color:#cce">firehawk@haxor.fi</a>.<br/>Código fuente disponible en <a style="color:#cce;" target="_blank" href="https://github.com/jhalme/webaudio-mod-player">GitHub</a> bajo licencia MIT.
+ <!--
+ The player has been tested on Chrome 14+, Firefox 24+, Safari 6+ and Edge 20+ so far. <span style="color:#faa">Disable AdBlock if you get cuts or stuttering!</span>
+ To report bugs, suggest features or request songs, contact me on <a href="https://twitter.com/janihalme" style="color:#cce;">Twitter</a> or
+ email <a href="mailto:firehawk@haxor.fi" style="color:#cce">firehawk@haxor.fi</a>.
+ Source code available on .-->
+ </div>
+ </div>
+
+
+ </div>
+ </body>
+</html>
diff --git a/cgi/templates/navbar.html b/cgi/templates/navbar.html
new file mode 100644
index 0000000..1655f0b
--- /dev/null
+++ b/cgi/templates/navbar.html
@@ -0,0 +1,16 @@
+<a id="noticias" href="/noticias/">Actualidad</a>
+<a id="tech" href="/tech/">Tecnolog&iacute;a</a>
+<a id="juegos" href="/juegos/">Juegos</a>
+<a id="musica" href="/musica/">M&uacute;sica</a>
+<a id="tv" href="/tv/">TV y Cine</a>
+<a id="letras" href="/letras/">Humanidades</a>
+<a id="zonavip" href="/zonavip/">Club VIP</a>
+<a id="world" href="/world/">World Lobby</a>
+|
+<a id="img" href="/img/">Imágenes</a>
+<a id="2d" href="/2d/">二次元画像</a>
+<a id="n" href="/n/">Naturaleza</a>
+<a id="o" href="/o/">Oekaki</a>
+<a id="0" href="/0/">Cero</a>
+|
+<a id="bai" href="/bai/">Meta</a> \ No newline at end of file
diff --git a/cgi/templates/paint.html b/cgi/templates/paint.html
new file mode 100644
index 0000000..476babe
--- /dev/null
+++ b/cgi/templates/paint.html
@@ -0,0 +1,79 @@
+<?py include('templates/base_top.html') ?>
+<?py if selfy: ?>
+<script type="text/javascript" src="#{static_url}js/palette_selfy.js"></script>
+<?py #endif ?>
+<center>
+<?py if applet == 'shipainter': ?>
+<applet id="oekaki" code="c.ShiPainter.class" archive="#{boards_url}oek_temp/spainter_all.jar" width="#{width+250}" height="#{height+280}" mayscript="">
+ <?py for key, value in params.iteritems(): ?>
+ <param name="#{key}" value="#{value}" />
+ <?py #endfor ?>
+</applet>
+<?py if selfy: ?>
+<script type="text/javascript">palette_selfy();</script>
+<?py #endif ?>
+<?py elif applet == 'neo': ?>
+<link rel="stylesheet" href="#{static_url}js/paintbbs/PaintBBS-1.3.4.css" type="text/css" />
+<script src="#{static_url}js/paintbbs/PaintBBS-1.3.4.js" charset="UTF-8"></script>
+<applet-dummy id="oekaki" name="paintbbs" width="#{width+250}" height="#{height+280}">
+<param name="image_width" value="#{width}">
+<param name="image_height" value="#{height}">
+<param name="image_bkcolor" value="#FFFFFF">
+<param name="image_size" value="0">
+<param name="undo" value="90">
+<param name="undo_in_mg" value="15">
+<param name="color_text" value="#EFEFFF">
+<param name="color_bk" value="#E8EFFF">
+<param name="color_bk2" value="#D5D8EF">
+<param name="color_icon" value="#A1B8D8">
+<param name="color_iconselect" value="#000000">
+<param name="url_save" value="/oek_temp/save.php?applet=paintbbs">
+<param name="url_exit" value="#{cgi_url}oekaki/finish/#{board}/#{replythread}">
+<param name="poo" value="false">
+<param name="send_advance" value="true">
+<param name="thumbnail_width" value="100%">
+<param name="thumbnail_height" value="100%">
+<param name="tool_advance" value="true">
+<param name="tool_color_button" value="#D2D8FF">
+<param name="tool_color_button2" value="#D2D8FF">
+<param name="tool_color_text" value="#5A5781">
+<param name="tool_color_bar" value="#D2D8F0">
+<param name="tool_color_frame" value="#7474AB">
+<?py if edit: ?>
+<param name="image_canvas" value="#{edit}">
+<?py #endif ?>
+</applet-dummy>
+<?py elif applet == 'wpaint': ?>
+<script type="text/javascript" src="#{static_url}js/wpaint/lib/jquery.1.10.2.min.js"></script>
+<script type="text/javascript" src="#{static_url}js/wpaint/lib/jquery.ui.core.1.10.3.min.js"></script>
+<script type="text/javascript" src="#{static_url}js/wpaint/lib/jquery.ui.widget.1.10.3.min.js"></script>
+<script type="text/javascript" src="#{static_url}js/wpaint/lib/jquery.ui.mouse.1.10.3.min.js"></script>
+<script type="text/javascript" src="#{static_url}js/wpaint/lib/jquery.ui.draggable.1.10.3.min.js"></script>
+<link rel="Stylesheet" type="text/css" href="#{static_url}js/wpaint/lib/wColorPicker.min.css" />
+<script type="text/javascript" src="#{static_url}js/wpaint/lib/wColorPicker.min.js"></script>
+<link rel="Stylesheet" type="text/css" href="#{static_url}js/wpaint/wPaint.min.css" />
+<script type="text/javascript" src="#{static_url}js/wpaint/wPaint.min.js"></script>
+<script type="text/javascript" src="#{static_url}js/wpaint/plugins/main/wPaint.menu.main.min.js"></script>
+<script type="text/javascript" src="#{static_url}js/wpaint/plugins/text/wPaint.menu.text.min.js"></script>
+<script type="text/javascript" src="#{static_url}js/wpaint/plugins/shapes/wPaint.menu.main.shapes.min.js"></script>
+<script type="text/javascript" src="#{static_url}js/wpaint/plugins/file/wPaint.menu.main.file.min.js"></script>
+<div id="wPaint" style="position:relative; width:#{width}px; height:#{height}px; background-color:#7a7a7a; margin:70px auto 20px auto;"></div>
+<script type="text/javascript" src="#{static_url}js/wpaint/bai.js"></script>
+<?py elif applet == 'tegaki': ?>
+<form id="imgform" data-w="#{width}" data-h="#{height}" action="#{cgi_url}oekaki/finish/#{board}/#{replythread}" method="post">
+<input type="hidden" name="filebase" id="filebase" />
+</form>
+<link rel="Stylesheet" type="text/css" href="#{static_url}js/tegaki/tegaki.css" />
+<script type="text/javascript" src="#{static_url}js/tegaki/tegaki.js"></script>
+<div id="buttons"><button id="topen">Abrir Tegaki</button></div>
+<div style="font-size:20pt" id="status"></div>
+<?py #endif ?>
+
+<br /><br /><br />
+<div id="links">
+<a href="#{boards_url}#{board}">Volver</a><br />
+<a id="finish" href="#{cgi_url}oekaki/finish/#{board}/#{replythread}">Recuperar dibujo guardado</a>
+</div>
+</center>
+<br />
+<?py include('templates/base_bottom.html') ?>
diff --git a/cgi/templates/redirect.html b/cgi/templates/redirect.html
new file mode 100644
index 0000000..172425d
--- /dev/null
+++ b/cgi/templates/redirect.html
@@ -0,0 +1,12 @@
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+<title>Has posteado en Bienvenido a Internet BBS/IB</title>
+<meta http-equiv="content-type" content="application/xhtml+xml; charset=UTF-8" />
+<meta http-equiv="refresh" content="0;url=#{url}" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+</head>
+<body style="text-align:center;">
+<h1>Gracias por tu post</h1><h3>${message}</h3><em>(por favor espera)</em>
+<?py if timetaken: ?><p style="font-size:small">Tiempo usado: #{timetaken}</p><?py #endif ?>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/report.html b/cgi/templates/report.html
new file mode 100644
index 0000000..d37ca6d
--- /dev/null
+++ b/cgi/templates/report.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+<?py if finished: ?><title>Post denunciado</title>
+<?py else: ?><title>Denunciar post ${postshow}</title>
+<?py #endif ?>
+<meta http-equiv="Content-Type" content="application/xhtml+xml; charset=UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<style>*{box-sizing:border-box} body{max-width:350px;margin:0 auto;text-align:justify} h1{text-align:left} a{color:#06C} a:active{color:#F30}
+input{border:1px solid #BBB;width:100%} .long{display:block}</style>
+</head>
+<body>
+<?py if finished: ?>
+<hr /><h1>Post denunciado.</h1>
+<hr /><a href="javascript:void(0)" onclick="history.go(-2);return(false);" class="long">Volver</a><hr />
+<?py else: ?>
+<hr /><h1>Formulario de denuncia</h1>
+<hr />Para pedir que el post <b>${postshow}</b> sea eliminado, indica una razón y presiona el botón [Enviar denuncia].
+<hr /><a href="javascript:void(0)" onclick="history.go(-1);return(false);" class="long">Volver</a>
+<hr /><form method="post" action=""><input type="text" name="reason" placeholder="Razón" maxlength="100" style="margin-bottom:0.5em;" /><input type="submit" value="Enviar denuncia" /></form>
+<hr />Este formulario no es para eliminar tu propio post.
+<?py if txt: ?>Para eliminar tu propio post debes presionar el botón <u>del</u> que aparece a la derecha de tu post cuando le pones el cursor encima. [<a href="/faq.html#del">info</a>]
+<?py else: ?>Para eliminar tu propio post debes chequear la caja que se encuentra en la parte superior izquierda de tu post y luego presionar el botón "Eliminar" que se encuentra al final de la página. [<a href="/faq.html#del">info</a>]<?py #endif ?>
+<hr />Normalmente eliminamos los mensajes que son considerados spam o flood. Si deseas pedir la prohibición de acceso a algún usuario persistente, te recomendamos hacerlo en la sección <a href="/bai/">meta</a>.
+<hr /><a href="mailto:burocracia@bienvenidoainternet.org" class="long">Contacto</a><hr />
+<?py #endif ?>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/revision.html b/cgi/templates/revision.html
new file mode 100644
index 0000000..1e9b46b
--- /dev/null
+++ b/cgi/templates/revision.html
@@ -0,0 +1 @@
+0.8.7
diff --git a/cgi/templates/stats.html b/cgi/templates/stats.html
new file mode 100644
index 0000000..dd0e5ab
--- /dev/null
+++ b/cgi/templates/stats.html
@@ -0,0 +1,163 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+<title>Estadísticas@Bienvenido a Internet</title>
+<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<style>
+body{font-family:arial,sans-serif;background:#090909;color:#fdfdfd;margin:0;text-align:center}
+a,a:visited{color:#fdfdfd;text-decoration:none}
+a:hover{text-decoration:underline}
+hr{margin:1em 0}
+span{display:inline-block}
+#title{margin-top:1em}
+#title a{text-decoration:none}
+.t1{font-size:1.8em;display:inline-block;line-height:1em}
+.t2{font-size:1em;margin-top:2px}
+h2{font-size:1.5em;margin:0 0 .2em 0}
+table{font-size:1.4em;margin:0 auto 1em}
+th,td{padding:10px;border-top:1px solid #222;border-left:1px solid #222}
+.boards{padding:0}
+td a{display:block;padding:10px}
+.desc tr{text-align:right}
+.desc td{text-align:left}
+.r{text-align:right}
+.l{text-align:left}
+.pos{line-height:0em;text-align:center}
+.uno{font-size:2em;color:red}
+.dos{font-size:1.6em;color:orange}
+.tres{font-size:1.3em;color:yellow}
+.etc{color:grey}
+#foot{margin:1em;font-size:.9em}
+#foot a{color:#999}
+@media (max-width:600px){
+.t1{font-size:1.6em}
+.t2{font-size:.9em}
+h2{font-size:1.2em}
+table{font-size:1.1em}
+table.desc{font-size:.9em}
+th,td{padding:5px}
+td a{display:block;padding:5px}
+.uno{font-size:1.6em}
+.dos{font-size:1.4em}
+.tres{font-size:1.2em}
+#boards th{font-size:.8em}
+.long{word-break:break-all}
+#foot{font-size:12px}
+}
+</style>
+</head>
+<body>
+<div id="title">
+ <div class="t1"><a href="/" style="font-weight:900">Bienvenido a Internet</a> Estadísticas</div>
+ <div class="t2">
+ <span>Última actualización:</span> <span>${timestamp} GMT${tz}</span>
+ <?py if not regenerated: ?>
+ <span>(en caché)</span>
+ <?py #endif ?>
+ </div>
+</div>
+
+<hr />
+
+<h2 class="rot">Mensajes totales <span>(última semana)</span></h2>
+ <table>
+ <tr>
+ <th>D&iacute;a</th>
+ <th class="r">Hilos</th>
+ <th class="r">Resp.</th>
+ <th class="r">Total</th>
+ </tr>
+ <?py allthreads, allposts = 0, 0 ?>
+ <?py for day, posts, threads in reversed(days): ?>
+ <tr>
+ <td>${day}</td>
+ <td class="r">${threads}</td>
+ <td class="r">${int(posts)-int(threads)}</td>
+ <td class="r">${posts}</td>
+ </tr>
+ <?py allthreads += int(threads) ?>
+ <?py allposts += int(posts) ?>
+ <?py #endfor ?>
+ <tr style="font-weight:bold;">
+ <td class="l">Total</td>
+ <td class="r">${allthreads}</td>
+ <td class="r">${allposts-allthreads}</td>
+ <td class="r">${allposts}</td>
+ </tr>
+ </table>
+
+<hr />
+
+<h2 class="rot">Volumen de mensajes por sección <span>(últimos 30 días)</span></h2>
+ <table id="boards">
+ <tr>
+ <th class="pos">#</th>
+ <th class="l">Sección</th>
+ <th>Mensajes</th>
+ <th>Porcentaje</th>
+ </tr>
+ <?py iter = 1 ?>
+ <?py for dir, board, percent, num in boards_percent: ?>
+ <tr>
+ <td class="pos">
+ <?py if iter == 1: ?><span class="uno">${iter}</span>
+ <?py elif iter == 2: ?><span class="dos">${iter}</span>
+ <?py elif iter == 3: ?><span class="tres">${iter}</span>
+ <?py else: ?>${iter}<?py #endif ?>
+ </td>
+ <td class="l boards"><a href="/${dir}/" target="_blank">${board}</a></td>
+ <td class="r">${num}</td>
+ <td class="r">${percent}%</td>
+ </tr>
+ <?py iter += 1 ?>
+ <?py #endfor ?>
+ </table>
+
+<hr />
+
+<h2>Sistema</h2>
+ <table class="desc">
+ <tr><th>Máquina</th>
+ <td>maria (Debian GNU/Linux)</td></tr>
+ <tr><th>OS</th>
+ <td class="long">${uname[0]} ${uname[2]}</td></tr>
+ <tr><th>Release/Arq.</th>
+ <td>${uname[3]} ${uname[4]}</td></tr>
+ <tr><th>Motor BBS/IB</th>
+ <td>weabot ${weabot_ver}</td></tr>
+ <tr><th>Templating</th>
+ <td>tenjin ${tenjin_ver}</td></tr>
+ <tr><th>Versión de Python</th>
+ <td>${python_ver}</td></tr>
+ <tr><th>Implementación</th>
+ <td>${python_impl}</td></tr>
+ <tr><th>Build</th>
+ <td>${python_build}</td></tr>
+ <tr><th>Compilado en</th>
+ <td>${python_compiler}</td></tr>
+ </table>
+
+<hr />
+
+<h2>Base de datos</h2>
+ <table class="desc">
+ <tr><th>Base de Datos</th>
+ <td>MariaDB</td></tr>
+ <tr><th>Versión</th>
+ <td>${mysql_ver} Linux x86_64</td></tr>
+ <tr><th>Mensajes totales activos</th>
+ <td>${total}</td></tr>
+ <tr><th>Archivos totales activos</th>
+ <td>${total_files}</td></tr>
+ <tr><th>Mensajes totales archivados (BBS)</th>
+ <td>${total_archived}</td></tr>
+ <tr><th>Mensajes totales archivados (IB)</th>
+ <td>0 (QEPD)</td></tr>
+ </table>
+
+<hr />
+
+<div id="foot">B.a.I. - 2010-2019 · Contacto: <a href="mailto:burocracia@bienvenidoainternet.org">burocracia@bienvenidoainternet.org</a></div>
+</body>
+</html>
diff --git a/cgi/templates/txt_archive.html b/cgi/templates/txt_archive.html
new file mode 100644
index 0000000..b1e25db
--- /dev/null
+++ b/cgi/templates/txt_archive.html
@@ -0,0 +1,104 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <title>#{threads[0]['subject']} - Archivo de #{board_name}@Bienvenido a Internet BBS</title>
+ <meta http-equiv="Content-Type" content="application/xhtml+xml;charset=utf-8" />
+<?py if threads: ?>
+ <meta property="og:site_name" content="Bienvenido a Internet BBS" />
+ <meta property="twitter:site" content="Bienvenido a Internet BBS" />
+ <meta name="description" content="${preview}" />
+ <meta property="og:title" content="${threads[0]['subject']} - ${board_name}" />
+ <meta property="og:description" content="${preview}" />
+ <meta property="twitter:title" content="${threads[0]['subject']} - ${board_name}" />
+ <meta name="twitter:description" content="${preview}" />
+<?py #endif ?>
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <link rel="shortcut icon" href="#{static_url}img/favicon.ico" />
+ <link rel="stylesheet" href="/static/css/txt/bbs.css" />
+<?py if not force_css: ?>
+ <link rel="stylesheet" id="css" href="#{static_url}css/txt/#{txt_styles[txt_styles_default].lower()}.css" />
+<?py else: ?>
+ <link rel="stylesheet" type="text/css" href="#{force_css}" />
+<?py #endif ?>
+<?py if board in ['zonavip', 'world']: ?>
+ <link rel="stylesheet" href="/static/css/txt/sjis.css" />
+<?py #endif ?>
+ <script type="text/javascript" src="#{static_url}js/weabotxt.js"></script>
+ <script type="text/javascript" src="#{static_url}js/aquiencitas.js"></script>
+</head>
+<body class="threadpage archived" data-brd="#{board}">
+<?py if threads: ?>
+<?py for thread in threads: ?>
+<div id="thread_nav">
+ <a href="/" name="top" target="_top">Bienvenido a Internet</a>
+ <a href="#{boards_url}#{board}/">■Volver al BBS■</a>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/">Hilo completo</a>
+ <?py if thread['length'] > 100: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/-100">1-</a>
+ <?py #endif ?>
+ <?py for i in range(thread['length'] / 100): ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/#{(i+1)*100+1}-#{(i+2)*100}">#{(i+1)*100+1}-</a>
+ <?py #endfor ?>
+ <?py if thread['length'] > 51: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/l50">Últimos 50</a>
+ <?py #endif ?>
+ <a href="#bottom">&#9660;Bajar&#9660;</a>
+</div>
+<hr /><div class="stop red">■ Este hilo se encuentra guardado en el archivo</div><hr />
+<div class="thread" data-length="#{thread['length']}">
+ <h3>#{thread['subject']} <span>(${(str(thread['length'])+" respuestas") if thread['length'] > 1 else "Una respuesta"})</span></h3>
+ <?py for post in thread['posts']: ?>
+ <?py if post['IS_DELETED'] == '1': ?>
+ <h4 class="deleted">#{post['num']} : Mensaje eliminado por el usuario.</h4>
+ <?py elif post['IS_DELETED'] == '2': ?>
+ <h4 class="deleted">#{post['num']} : Mensaje eliminado por miembro del staff.</h4>
+ <?py else: ?>
+ <?py if post['num'] == 1: ?>
+ <div class="reply first" data-n="#{post['num']}">
+ <?py else: ?>
+ <div class="reply" data-n="#{post['num']}">
+ <?py #endif ?>
+ <h4>#{post['num']} :
+ <?py if post['email']: ?>
+ <?py if post['tripcode']: ?>
+ <a href="mailto:#{post['email']}"><span class="name"><b>#{post['name']}</b> #{post['tripcode']}</span></a>
+ <?py else: ?>
+ <a href="mailto:#{post['email']}"><span class="name"><b>#{post['name']}</b></span></a>
+ <?py #endif ?>
+ <?py else: ?>
+ <?py if post['tripcode']: ?>
+ <span class="name"><b>#{post['name']}</b> #{post['tripcode']}</span>
+ <?py else: ?>
+ <span class="name"><b>#{post['name']}</b></span>
+ <?py #endif ?>
+ <?py #endif ?> : <span class="date">#{post['timestamp_formatted']}</span></h4>
+ <div class="msg">#{post['message']}</div>
+ </div>
+ <?py #endif ?>
+ <?py #endfor ?>
+ <?py if 'size' in thread: ?>
+ <div class="size">#{thread['size']}</div>
+ <?py #endif ?>
+</div>
+<hr /><div class="stop red">■ Este hilo se encuentra guardado en el archivo</div><hr />
+<form class="threadlinks">
+ <a href="#{boards_url}#{board}/">■Volver al BBS■</a>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/">Hilo completo</a>
+ <?py if prevrange: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/#{prevrange}">Anteriores 100</a>
+ <?py #endif ?>
+ <?py if nextrange: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/#{nextrange}">Próximos 100</a>
+ <?py #endif ?>
+ <?py if thread['length'] > 51: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/l50">Últimos 50</a>
+ <?py #endif ?>
+ <a href="#top">&#9650;Subir&#9650;</a>
+</form>
+<?py #endfor ?>
+<?py #endif ?>
+<div class="end">weabot.py ver <?py include('templates/revision.html') ?> Bienvenido a Internet BBS/IB</div>
+<a name="bottom"></a>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/txt_base_top.html b/cgi/templates/txt_base_top.html
new file mode 100644
index 0000000..eb3c37b
--- /dev/null
+++ b/cgi/templates/txt_base_top.html
@@ -0,0 +1,44 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+<?py if replythread and threads: ?>
+ <title>#{threads[0]['subject']} - #{board_name}@Bienvenido a Internet BBS</title>
+<?py elif board: ?>
+ <title>#{board_long}</title>
+<?py else: ?>
+ <title>#{title}</title>
+<?py #endif ?>
+ <meta http-equiv="Content-Type" content="application/xhtml+xml;charset=utf-8" />
+<?py if replythread and threads: ?>
+ <meta property="og:site_name" content="Bienvenido a Internet BBS" />
+ <meta property="twitter:site" content="Bienvenido a Internet BBS" />
+ <meta name="description" content="${preview}" />
+ <meta property="og:title" content="${threads[0]['subject']} - ${board_name}" />
+ <meta property="og:description" content="${preview}" />
+ <meta property="twitter:title" content="${threads[0]['subject']} - ${board_name}" />
+ <meta name="twitter:description" content="${preview}" />
+<?py #endif ?>
+ <meta name="robots" content="#{"noindex" if noindex else "index, follow"}" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <link rel="shortcut icon" href="/favicon.ico" />
+ <link rel="stylesheet" href="/static/css/txt/bbs.css" />
+<?py if not force_css: ?>
+ <link rel="stylesheet" id="css" href="#{static_url}css/txt/#{txt_styles[txt_styles_default].lower()}.css" />
+<?py else: ?>
+ <link rel="stylesheet" type="text/css" href="#{force_css}" />
+<?py #endif ?>
+<?py if board in ['zonavip', 'world']: ?>
+ <link rel="stylesheet" href="/static/css/txt/sjis.css" />
+<?py #endif ?>
+<?py if board == 'polka': ?>
+ <script type="text/javascript" src="#{static_url}js/weabotxt.test.js"></script>
+<?py else: ?>
+ <script type="text/javascript" src="#{static_url}js/weabotxt.js"></script>
+<?py #endif ?>
+ <script type="text/javascript" src="#{static_url}js/aquiencitas.js"></script>
+ <script type="text/javascript" src="#{static_url}js/shobon.js"></script>
+<?py if replythread and board != 'polka': ?>
+ <script type="text/javascript" src="#{static_url}js/autorefresh.js"></script>
+<?py #endif ?>
+</head>
diff --git a/cgi/templates/txt_board.en.html b/cgi/templates/txt_board.en.html
new file mode 100644
index 0000000..8e3c421
--- /dev/null
+++ b/cgi/templates/txt_board.en.html
@@ -0,0 +1,137 @@
+<?py include('templates/txt_base_top.html') ?>
+<body class="mainpage" data-brd="#{board}">
+<div id="main_nav"><a href="/" target="_top">Bienvenido a Internet</a> | <?py include('templates/navbar.html') ?></div>
+<?py if banner_url: ?>
+ <img class="banner" src="#{banner_url}" style="width:#{banner_width}px;height:#{banner_height}px;" />
+<?py #endif ?>
+<div id="titlebox" class="outerbox">
+ <div class="innerbox">
+ <div class="threadnav"><a href="#menu" title="Thread list">&#9632;</a><a href="#1" title="Next thread">&#9660;</a></div>
+ <h1>#{board_long}</h1>
+ <?py if postarea_desc: ?>
+ <div id="rules">#{postarea_desc}</div>
+ <?py #endif ?>
+ <form method="get" action="/tools/search.py" id="search"><input type="text" name="q" value="" /><input type="hidden" name="board" value="#{board}" /><input type="submit" value="Search active posts" /><input type="submit" value="Search archives" formaction="/tools/search_kako.py" /></form>
+ </div>
+ <div class="innerbox links"><a href="/guia.html"><b>C&oacute;mo postear</b></a> | <a href="/faq.html"><b>Preguntas frecuentes</b></a> | <a href="/bai/"><b>Contacto</b></a>
+ <?py if not force_css: ?>| <b>Styles:</b>
+ <?py for title in txt_styles: ?><a href="#" class="ss">#{title}</a> <?py #endfor ?>
+ <?py #endif ?></div>
+</div>
+<?py if postarea_extra: ?>
+<div class="outerbox"><div class="innerbox">#{postarea_extra}</div></div>
+<?py #endif ?>
+<a name="menu"></a>
+<?py if threads: ?>
+<div id="threadbox" class="outerbox"><div class="innerbox">
+ <div id="threadlinks"><a href="#{cgi_url}threadlist/#{board}"><b>View all threads</b></a> <a href="kako/"><b>View archive</b></a> <a href="#newthread"><b>Create new thread</b></a></div>
+ <div id="threadlist">
+ <?py iter = 1 ?>
+ <?py for thread in threads: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/#{'l50' if thread['length'] > 50 else ''}">#{iter}: </a><a href="##{iter}"> <b>#{thread['posts'][0]['subject']}</b> (#{thread['length']})</a><br />
+ <?py iter += 1 ?>
+ <?py #endfor ?>
+ <?py for thread in more_threads: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/#{'l50' if thread['length'] > 50 else ''}">#{iter}: <b>#{thread["subject"]}</b> (#{thread["length"]})</a><br />
+ <?py iter += 1 ?>
+ <?py #endfor ?>
+ </div>
+</div></div>
+<?py titer = 1 ?>
+<?py for thread in threads: ?>
+<a name="#{titer}"></a>
+<div class="thread"><div class="innerbox">
+<div class="threadnav"><a href="#menu" title="Thread list">&#9632;</a><a href="##{(titer-1) if titer>1 else len(threads)}" title="Previous thread">&#9650;</a><a href="##{(titer+1) if titer<len(threads) else '1'}" title="Next thread">&#9660;</a></div>
+<h2><small>[#{titer}:#{thread['length']}]</small><a href="#{boards_url}#{board}/read/#{thread['timestamp']}/#{'l50' if thread['length'] > 50 else ''}">#{thread['posts'][0]['subject']}</a></h2>
+<?py for post in thread['posts']: ?>
+<?py if post['IS_DELETED'] == '1': ?>
+ <h4 class="deleted">#{post['num']} : Post deleted by user.</h4>
+<?py elif post['IS_DELETED'] == '2': ?>
+ <h4 class="deleted">#{post['num']} : Post deleted by staff.</h4>
+<?py else: ?>
+ <div class="reply#{' first' if post['num'] == 1 else ''}" data-n="#{post['num']}">
+ <h4>#{post['num']} :
+ <?py if post['email']: ?>
+ <?py if post['tripcode']: ?>
+ <a href="mailto:#{post['email']}"><span class="name"><b>#{post['name']}</b> #{post['tripcode']}</span></a>
+ <?py else: ?>
+ <a href="mailto:#{post['email']}"><span class="name"><b>#{post['name']}</b></span></a>
+ <?py #endif ?>
+ <?py else: ?>
+ <?py if post['tripcode']: ?>
+ <span class="name"><b>#{post['name']}</b> #{post['tripcode']}</span>
+ <?py else: ?>
+ <span class="name"><b>#{post['name']}</b></span>
+ <?py #endif ?>
+ <?py #endif ?> : <span class="date" data-unix="#{post['timestamp']}">#{post['timestamp_formatted']}</span></h4>
+ <?py if post['file']: ?>
+ <a href="#{images_url}#{board}/src/#{post['file']}" target="_blank" class="thumb"><img src="#{images_url}#{board}/thumb/#{post['thumb']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" /><div>${int(post['file_size'])//1024}KB ${post['file'].split(".")[1].upper()}</div></a>
+ <?py #endif ?>
+ <div class="msg">
+ #{post['message']}
+ <?py if post['shortened']: ?>
+ <div class="abbrev">(Post is too long... Click <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/#{post['num']}">here</a> to view the whole post.)</div>
+ <?py #endif ?>
+ </div>
+</div>
+<?py #endif ?>
+<?py #endfor ?>
+<?py if thread['locked'] != '1': ?>
+<form id="postform#{thread['id']}" class="postform" action="#{cgi_url}post" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="board" value="#{board}" /><input type="hidden" name="parent" value="#{thread['id']}" /><input type="hidden" name="password" value="" />
+ <div style="display:none"><input type="text" name="name" size="15" /> <input type="text" name="email" size="15" /></div>
+ <span><input type="submit" value="Reply" /></span> <span><span>Name:&nbsp;</span><input type="text" name="fielda" size="15" /><span>&nbsp;E-mail:&nbsp;</span><input type="text" name="fieldb" size="15" /></span><br />
+ <div class="formpad">
+ <textarea name="message" cols="70" rows="5"></textarea>
+ <?py if allow_image_replies: ?><br /><input type="file" name="file" /><?py #endif ?>
+<?py else: ?>
+<form class="postform"><div class="locked">This thread has been closed. You cannot post in it any longer.</div><div class="formpad">
+<?py #endif ?>
+ <div class="threadlinks">
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/"><b>Entire thread</b></a>
+ <?py if thread['length'] > 51: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/l50"><b>Last 50</b></a>
+ <?py #endif ?>
+ <?py if thread['length'] > 101: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/-100"><b>First 100</b></a>
+ <?py #endif ?>
+ <a href="#menu"><b>Thread list</b></a>
+ <a href="#newthread"><b>New thread</b></a>
+ </div>
+</div></form>
+</div></div>
+<?py titer += 1 ?>
+<?py #endfor ?>
+<?py #endif ?>
+<a name="newthread"></a>
+<div id="createbox" class="outerbox">
+ <div class="extrabox"></div>
+ <div class="innerbox">
+ <div class="threadnav"><a href="#menu" title="Thread list">&#9632;</a></div>
+ <h5>New thread form</h5>
+ <form id="postform0" action="#{cgi_url}post" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="board" value="#{board}" /><input type="hidden" name="password" value="" />
+ <table style="max-width:600px">
+ <tr>
+ <td class="pblock">Subject:</td>
+ <td colspan="3" style="width:100%"><input type="text" name="subject" size="50" maxlength="100" /></td>
+ <td><input type="submit" value="Create new thread" /></td>
+ </tr>
+ <tr>
+ <td class="pblock">Name:</td><td><input type="text" name="fielda" /></td>
+ <td class="pblock">E-mail:</td><td><input type="text" name="fieldb" /></td>
+ <td><input type="button" name="preview" value="Preview" /></td>
+ </tr>
+ <tr id="options" style="display:none"><td></td><td colspan="4"><div id="preview0" class="msg"></div></td></tr>
+ <tr><td class="pblock">Body:</td><td colspan="4"><textarea name="message" cols="70" rows="10"></textarea></td></tr>
+ <?py if allow_images: ?>
+ <tr><td class="pblock">File:</td><td colspan="4"><input type="file" name="file" /></td></tr>
+ <?py #endif ?>
+ </table>
+ <div style="display:none">Trampa: <input type="text" name="name" maxlength="50" /> <input type="text" name="email" maxlength="50" /></div>
+ </form>
+ </div>
+</div>
+<center id="footer"><a href="/" target="_top">Bienvenido a Internet BBS/IB</a> weabot.py <?py include('templates/revision.html') ?> + FastCGI + tenjin<br />それがBaIクオリティー!</center>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/txt_board.html b/cgi/templates/txt_board.html
new file mode 100644
index 0000000..097e255
--- /dev/null
+++ b/cgi/templates/txt_board.html
@@ -0,0 +1,137 @@
+<?py include('templates/txt_base_top.html') ?>
+<body class="mainpage" data-brd="#{board}">
+<div id="main_nav"><a href="/" target="_top">Bienvenido a Internet</a> | <?py include('templates/navbar.html') ?></div>
+<?py if banner_url: ?>
+ <img class="banner" src="#{banner_url}" style="width:#{banner_width}px;height:#{banner_height}px;" />
+<?py #endif ?>
+<div id="titlebox" class="outerbox">
+ <div class="innerbox">
+ <div class="threadnav"><a href="#menu" title="Ir a lista de hilos">&#9632;</a><a href="#1" title="Ir a primer hilo">&#9660;</a></div>
+ <h1>#{board_long}</h1>
+ <?py if postarea_desc: ?>
+ <div id="rules">#{postarea_desc}</div>
+ <?py #endif ?>
+ <form method="get" action="/tools/search.py" id="search"><input type="text" name="q" value="" /><input type="hidden" name="board" value="#{board}" /><input type="submit" value="Buscar en mensajes activos" /><input type="submit" value="Buscar en archivo" formaction="/tools/search_kako.py" /></form>
+ </div>
+ <div class="innerbox links"><b>¿Eres nuevo?</b> <a href="/guia.html"><b>C&oacute;mo postear</b></a> | <a href="/faq.html"><b>Preguntas frecuentes</b></a> | <a href="/bai/"><b>Contacto</b></a>
+ <?py if not force_css: ?>| <b>Estilo:</b>
+ <?py for title in txt_styles: ?><a href="#" class="ss">#{title}</a> <?py #endfor ?>
+ <?py #endif ?></div>
+</div>
+<?py if postarea_extra: ?>
+<div class="outerbox"><div class="innerbox">#{postarea_extra}</div></div>
+<?py #endif ?>
+<a name="menu"></a>
+<?py if threads: ?>
+<div id="threadbox" class="outerbox"><div class="innerbox">
+ <div id="threadlinks"><a href="#{cgi_url}threadlist/#{board}"><b>Ver todos los hilos</b></a> <a href="kako/"><b>Ver hilos archivados</b></a> <a href="#newthread"><b>Crear nuevo hilo</b></a></div>
+ <div id="threadlist">
+ <?py iter = 1 ?>
+ <?py for thread in threads: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/#{'l50' if thread['length'] > 50 else ''}">#{iter}: </a><a href="##{iter}"> <b>#{thread['posts'][0]['subject']}</b> (#{thread['length']})</a><br />
+ <?py iter += 1 ?>
+ <?py #endfor ?>
+ <?py for thread in more_threads: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/#{'l50' if thread['length'] > 50 else ''}">#{iter}: <b>#{thread["subject"]}</b> (#{thread["length"]})</a><br />
+ <?py iter += 1 ?>
+ <?py #endfor ?>
+ </div>
+</div></div>
+<?py titer = 1 ?>
+<?py for thread in threads: ?>
+<a name="#{titer}"></a>
+<div class="thread"><div class="innerbox">
+<div class="threadnav"><a href="#menu" title="Lista de hilos">&#9632;</a><a href="##{(titer-1) if titer>1 else len(threads)}" title="Hilo anterior">&#9650;</a><a href="##{(titer+1) if titer<len(threads) else '1'}" title="Hilo siguiente">&#9660;</a></div>
+<h2><span>[#{titer}:#{thread['length']}]</span><a href="#{boards_url}#{board}/read/#{thread['timestamp']}/#{'l50' if thread['length'] > 50 else ''}">#{thread['posts'][0]['subject']}</a></h2>
+<?py for post in thread['posts']: ?>
+<?py if post['IS_DELETED'] == '1': ?>
+ <h4 class="deleted">#{post['num']} Mensaje eliminado por el usuario.</h4>
+<?py elif post['IS_DELETED'] == '2': ?>
+ <h4 class="deleted">#{post['num']} Mensaje eliminado por miembro del staff.</h4>
+<?py else: ?>
+ <div class="reply#{' first' if post['num'] == 1 else ''}" data-n="#{post['num']}">
+ <h4>#{post['num']} :
+ <?py if post['email']: ?>
+ <?py if post['tripcode']: ?>
+ <a href="mailto:#{post['email']}"><span class="name"><b>#{post['name']}</b> #{post['tripcode']}</span></a>
+ <?py else: ?>
+ <a href="mailto:#{post['email']}"><span class="name"><b>#{post['name']}</b></span></a>
+ <?py #endif ?>
+ <?py else: ?>
+ <?py if post['tripcode']: ?>
+ <span class="name"><b>#{post['name']}</b> #{post['tripcode']}</span>
+ <?py else: ?>
+ <span class="name"><b>#{post['name']}</b></span>
+ <?py #endif ?>
+ <?py #endif ?> : <span class="date" data-unix="#{post['timestamp']}">#{post['timestamp_formatted']}</span></h4>
+ <?py if post['file']: ?>
+ <a href="/#{board}/src/#{post['file']}" target="_blank" class="thumb"><img src="#{'/static/' if post['thumb'].startswith('mime') else ('/'+board+'/thumb/')}#{post['thumb']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" /><div>${int(post['file_size'])//1024}KB ${post['file'].split(".")[1].upper()}</div></a>
+ <?py #endif ?>
+ <div class="msg">
+ #{post['message']}
+ <?py if post['shortened']: ?>
+ <div class="abbrev">(Post muy largo... Presiona <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/#{post['num']}">aquí</a> para verlo completo.)</div>
+ <?py #endif ?>
+ </div>
+</div>
+<?py #endif ?>
+<?py #endfor ?>
+<?py if thread['locked'] != '1': ?>
+<form id="postform#{thread['id']}" class="postform" action="#{cgi_url}post" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="board" value="#{board}" /><input type="hidden" name="parent" value="#{thread['id']}" /><input type="hidden" name="password" value="" />
+ <div style="display:none"><input type="text" name="name" size="15" /> <input type="text" name="email" size="15" /></div>
+ <span><input type="submit" value="Responder" /></span> <span><span>Nombre:&nbsp;</span><input type="text" name="fielda" size="15" /><span>&nbsp;E-mail:&nbsp;</span><input type="text" name="fieldb" size="15" /></span>
+ <div class="formpad">
+ <textarea name="message" cols="70" rows="5"></textarea>
+ <?py if allow_image_replies: ?><br /><input type="file" name="file" /><?py #endif ?>
+<?py else: ?>
+<form class="postform"><div class="locked">El hilo ha sido cerrado. Ya no se puede postear en él.</div><div class="formpad">
+<?py #endif ?>
+ <div class="threadlinks">
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/"><b>Hilo completo</b></a>
+ <?py if thread['length'] > 51: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/l50"><b>&Uacute;ltimos 50</b></a>
+ <?py #endif ?>
+ <?py if thread['length'] > 101: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/-100"><b>Primeros 100</b></a>
+ <?py #endif ?>
+ <a href="#menu"><b>Lista de hilos</b></a>
+ <a href="#newthread"><b>Nuevo hilo</b></a>
+ </div>
+</div></form>
+</div></div>
+<?py titer += 1 ?>
+<?py #endfor ?>
+<?py #endif ?>
+<a name="newthread"></a>
+<div id="createbox" class="outerbox">
+ <div class="extrabox"></div>
+ <div class="innerbox">
+ <div class="threadnav"><a href="#menu" title="Lista de hilos">&#9632;</a></div>
+ <h5>Formulario de nuevo hilo</h5>
+ <form id="postform0" action="#{cgi_url}post" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="board" value="#{board}" /><input type="hidden" name="password" value="" />
+ <table style="max-width:600px">
+ <tr>
+ <td class="pblock">Asunto:</td>
+ <td colspan="3" style="width:100%"><input type="text" name="subject" size="50" maxlength="100" /></td>
+ <td><input type="submit" value="Crear nuevo hilo" /></td>
+ </tr>
+ <tr>
+ <td class="pblock">Nombre:</td><td><input type="text" name="fielda" /></td>
+ <td class="pblock">E-mail:</td><td><input type="text" name="fieldb" /></td>
+ <td><input type="button" name="preview" value="Previsualizar" /></td>
+ </tr>
+ <tr id="options" style="display:none"><td></td><td colspan="4"><div id="preview0" class="msg"></div></td></tr>
+ <tr><td class="pblock">Mensaje:</td><td colspan="4"><textarea name="message" cols="70" rows="10"></textarea></td></tr>
+ <?py if allow_images: ?>
+ <tr><td class="pblock">Archivo:</td><td colspan="4"><input type="file" name="file" /></td></tr>
+ <?py #endif ?>
+ </table>
+ <div style="display:none">Trampa: <input type="text" name="name" maxlength="50" /> <input type="text" name="email" maxlength="50" /></div>
+ </form>
+ </div>
+</div>
+<center id="footer"><a href="/" target="_top">Bienvenido a Internet BBS/IB</a> weabot.py <?py include('templates/revision.html') ?> + FastCGI + tenjin<br />No se ponga sensible, baisano...</center>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/txt_error.html b/cgi/templates/txt_error.html
new file mode 100644
index 0000000..8a16a63
--- /dev/null
+++ b/cgi/templates/txt_error.html
@@ -0,0 +1,50 @@
+<html>
+<head>
+<title>Error</title>
+<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<style type="text/css">
+* {word-wrap:break-word;}
+body {margin:8px;}
+.h {font-weight:bold; font-size:large;}
+.err {color:red;}
+.sub1 {color:#00AA00;} .sub2 {color:#DD00DD;}
+blockquote {margin-left:40px; margin-right:40px;}
+ul {padding-left:40px;}
+@media(max-width:650px){
+ blockquote {margin-left:20px; margin-right:20px;}
+ ul {padding-left:20px;}
+}
+</style>
+</head>
+<body>
+<div class="h err">ERROR: #{error}</div>
+<blockquote>
+ Host <b>${info['host']}</b><br>
+ <blockquote>
+ Nombre: <b>${info['name']}</b><br>
+ E-mail: ${info['email']}<br>
+ Mensaje: <br>
+ ${info['message']}
+ </blockquote>
+</blockquote>
+<hr>
+<ul>
+ <div class="h sub1">¿No sabes qué sucede?</div>
+ <ul style="line-height:1.5;">
+ ¡Revisemos!<br>
+ <b>
+ [<a href="/guia.html">¿Eres nuevo?</a>]<br />
+ [<a href="/faq.html">Preguntas frecuentes</a>]<br />
+ [<a href="#{boards_url}#{board}">Ir a la sección</a>]<br />
+ [<a href="#{cgi_url}threadlist/#{board}">Ir a la lista de hilos</a>]<br />
+ </b>
+ </ul><br>
+ <div class="h sub2">Contacto</div>
+ <ul style="line-height:1.5;">
+ Cualquier problema con el sitio por favor hacerlo llegar al staff de BaI.<br />
+ Para ello contáctanos en la <a href="/bai/">sección de discusión</a>.
+ </ul>
+</ul>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/txt_thread.en.html b/cgi/templates/txt_thread.en.html
new file mode 100644
index 0000000..2e811cb
--- /dev/null
+++ b/cgi/templates/txt_thread.en.html
@@ -0,0 +1,105 @@
+<?py include('templates/txt_base_top.html') ?>
+<body class="threadpage" data-brd="#{board}">
+<?py if threads: ?>
+<?py for thread in threads: ?>
+<div id="thread_nav">
+ <a href="/" name="top" target="_top">Bienvenido a Internet</a>
+ <a href="#{boards_url}#{board}/">■Return to BBS■</a>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/">Entire thread</a>
+ <?py if thread['length'] > 100: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/-100">First 100</a>
+ <?py #endif ?>
+ <?py if thread['length'] > 51: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/l50">Last 50</a>
+ <?py #endif ?>
+ <a href="#bottom">&#9660;Bottom&#9660;</a>
+</div>
+<hr />
+<?py if thread['length'] > 1000: ?>
+ <div class="stop red">The thread got over 1000 posts and has been closed.</div>
+<?py elif thread['length'] > 900: ?>
+ <div class="warn yellow">The thread has reached 900 posts. When it reaches 1000 posts it will be closed.</div>
+<?py #endif ?>
+<div class="thread" data-length="#{thread['length']}">
+ <h3>#{thread['subject']} <span>(${(str(thread['length'])+" replies") if thread['length']>1 else "1 reply"})</span></h3>
+ <?py for post in thread['posts']: ?>
+ <?py if post['IS_DELETED'] == '1': ?>
+ <h4 class="deleted">#{post['num']} : Post deleted by user.</h4>
+ <?py elif post['IS_DELETED'] == '2': ?>
+ <h4 class="deleted">#{post['num']} : Post deleted by staff.</h4>
+ <?py else: ?>
+ <?py if post['num'] == 1: ?>
+ <div class="reply first" data-n="#{post['num']}">
+ <?py else: ?>
+ <div class="reply" data-n="#{post['num']}">
+ <?py #endif ?>
+ <h4><a href="#" class="num">#{post['num']}</a> :
+ <?py if post['email']: ?>
+ <?py if post['tripcode']: ?>
+ <a href="mailto:#{post['email']}"><span class="name"><b>#{post['name']}</b> #{post['tripcode']}</span></a>
+ <?py else: ?>
+ <a href="mailto:#{post['email']}"><span class="name"><b>#{post['name']}</b></span></a>
+ <?py #endif ?>
+ <?py else: ?>
+ <?py if post['tripcode']: ?>
+ <span class="name"><b>#{post['name']}</b> #{post['tripcode']}</span>
+ <?py else: ?>
+ <span class="name"><b>#{post['name']}</b></span>
+ <?py #endif ?>
+ <?py #endif ?> : <span class="date" data-unix="#{post['timestamp']}">#{post['timestamp_formatted']}</span>
+ <span class="del"><a href="#{cgi_url}report/#{board}/#{post['id']}/#{post['num']}">rep</a> <a href="#">del</a></span></h4>
+ <?py if post['file']: ?>
+ <a href="#{images_url}#{board}/src/#{post['file']}" target="_blank" class="thumb"><img src="#{images_url}#{board}/thumb/#{post['thumb']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" /><div>${int(post['file_size'])//1024}KB ${post['file'].split(".")[1].upper()}</div></a>
+ <?py #endif ?>
+ <div class="msg">
+ #{post['message']}
+ </div>
+ </div>
+ <?py #endif ?>
+ <?py #endfor ?>
+ <div class="size">#{thread['size']}</div>
+</div>
+<hr />
+<?py if thread['locked'] != '1': ?>
+ <div class="lastposts"><a href="#{boards_url}#{board}/read/#{thread['timestamp']}/#{thread['length']}-n" id="n">Show new posts</a></div>
+ <hr />
+<?py #endif ?>
+<?py if thread['length'] > 1000: ?>
+ <div class="stop red">The thread got over 1000 posts and has been closed.</div>
+<?py elif thread['length'] > 950: ?>
+ <div class="warn red">The thread has reached 950 posts. When it reaches 1000 posts it will be closed.</div>
+<?py elif thread['length'] > 900: ?>
+ <div class="warn yellow">The thread has reached 900 posts. When it reaches 1000 posts it will be closed.</div>
+<?py #endif ?>
+<form id="postform#{thread['id']}" class="postform" name="postform" action="#{cgi_url}post" method="post" enctype="multipart/form-data">
+ <div class="threadlinks">
+ <a href="#{boards_url}#{board}">■Return to BBS■</a>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/">Entire thread</a>
+ <?py if prevrange: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/#{prevrange}">Previous 100</a>
+ <?py #endif ?>
+ <?py if nextrange: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/#{nextrange}">Next 100</a>
+ <?py #endif ?>
+ <?py if thread['length'] > 51: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/l50">Last 50</a>
+ <?py #endif ?>
+ <a href="#top">&#9650;Top&#9650;</a>
+ </div>
+ <input type="hidden" name="board" value="#{board}" /><input type="hidden" name="parent" value="#{thread['id']}" /><input type="hidden" name="password" value="" />
+ <?py if thread['locked'] != '1': ?>
+ <div style="display:none"><input type="text" name="name" size="13" /> <input type="text" name="email" size="13" /></div>
+ <span><input type="submit" value="Responder" accesskey="z" /> <input type="button" name="preview" value="Previsualizar" /></span> <span><span>Name:&nbsp;</span><input type="text" name="fielda" size="13" accesskey="n" /><span>&nbsp;E-mail:&nbsp;</span><input type="text" name="fieldb" size="13" accesskey="e" /></span><br />
+ <textarea name="message" cols="80" rows="7" accesskey="m"></textarea><br />
+ <div id="preview#{thread['id']}" class="msg" style="display:none"></div>
+ <?py if allow_image_replies: ?>
+ <input type="file" name="file" />
+ <?py #endif ?>
+ <?py #endif ?>
+</form>
+<?py #endfor ?>
+<?py #endif ?>
+<div class="end">weabot.py ver <?py include('templates/revision.html') ?> Bienvenido a Internet BBS/IB</div>
+<a name="bottom"></a>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/txt_thread.html b/cgi/templates/txt_thread.html
new file mode 100644
index 0000000..c438944
--- /dev/null
+++ b/cgi/templates/txt_thread.html
@@ -0,0 +1,101 @@
+<?py include('templates/txt_base_top.html') ?>
+<body class="threadpage" data-brd="#{board}">
+<?py if threads: ?>
+<?py for thread in threads: ?>
+<div id="thread_nav">
+ <a href="/" name="top" target="_top">Bienvenido a Internet</a>
+ <a href="#{boards_url}#{board}/">■Volver al BBS■</a>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/">Hilo completo</a>
+ <?py if thread['length'] > 100: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/-100">Primeros 100</a>
+ <?py #endif ?>
+ <?py if thread['length'] > 51: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/l50">Últimos 50</a>
+ <?py #endif ?>
+ <a href="#bottom">&#9660;Bajar&#9660;</a>
+</div>
+<hr />
+<?py if thread['length'] > 1000: ?>
+ <div class="stop red">El hilo superó los 1000 mensajes y ha sido cerrado. Ya no se puede postear en él.</div>
+<?py elif thread['length'] > 900: ?>
+ <div class="warn yellow">El hilo ha recibido más de 900 mensajes. Cuando llegue a 1000 será cerrado.</div>
+<?py #endif ?>
+<div class="thread" data-length="#{thread['length']}">
+ <h3>#{thread['subject']} <span>(${(str(thread['length'])+" respuestas") if thread['length']>1 else "Una respuesta"})</span></h3>
+ <?py for post in thread['posts']: ?>
+ <?py if post['IS_DELETED'] == '1': ?>
+ <h4 class="deleted">#{post['num']} : Mensaje eliminado por el usuario.</h4>
+ <?py elif post['IS_DELETED'] == '2': ?>
+ <h4 class="deleted">#{post['num']} : Mensaje eliminado por miembro del staff.</h4>
+ <?py else: ?>
+ <?py if post['num'] == 1: ?>
+ <div class="reply first" data-n="#{post['num']}">
+ <?py else: ?>
+ <div class="reply" data-n="#{post['num']}">
+ <?py #endif ?>
+ <h4><a href="#" class="num">#{post['num']}</a> :
+ <?py if post['email']: ?>
+ <?py if post['tripcode']: ?>
+ <a href="mailto:#{post['email']}"><span class="name"><b>#{post['name']}</b> #{post['tripcode']}</span></a>
+ <?py else: ?>
+ <a href="mailto:#{post['email']}"><span class="name"><b>#{post['name']}</b></span></a>
+ <?py #endif ?>
+ <?py else: ?>
+ <?py if post['tripcode']: ?>
+ <span class="name"><b>#{post['name']}</b> #{post['tripcode']}</span>
+ <?py else: ?>
+ <span class="name"><b>#{post['name']}</b></span>
+ <?py #endif ?>
+ <?py #endif ?> : <span class="date" data-unix="#{post['timestamp']}">#{post['timestamp_formatted']}</span>
+ <span class="del"><a href="#{cgi_url}report/#{board}/#{post['id']}/#{post['num']}">rep</a> <a href="#">del</a></span></h4>
+ <?py if post['file']: ?>
+ <a href="/#{board}/src/#{post['file']}" target="_blank" class="thumb"><img src="#{'/static/' if post['thumb'].startswith('mime') else ('/'+board+'/thumb/')}#{post['thumb']}" width="#{post['thumb_width']}" height="#{post['thumb_height']}" /><div>${int(post['file_size'])//1024}KB ${post['file'].split(".")[1].upper()}</div></a>
+ <?py #endif ?>
+ <div class="msg">#{post['message']}</div>
+ </div>
+ <?py #endif ?>
+ <?py #endfor ?>
+ <div class="size">#{thread['size']}</div>
+</div>
+<hr />
+<?py if thread['locked'] != '1': ?>
+ <div class="lastposts"><a href="#{boards_url}#{board}/read/#{thread['timestamp']}/#{thread['length']}-n" id="n">Ver nuevos posts</a></div>
+ <hr />
+<?py #endif ?>
+<?py if thread['length'] > 1000: ?>
+ <div class="stop red">El hilo superó los 1000 mensajes y ha sido cerrado. Ya no se puede postear en él.</div>
+<?py elif thread['length'] > 900: ?>
+ <div class="warn yellow">El hilo ha recibido más de 900 mensajes. Cuando llegue a 1000 será cerrado.</div>
+<?py #endif ?>
+<form id="postform#{thread['id']}" class="postform" name="postform" action="#{cgi_url}post" method="post" enctype="multipart/form-data">
+ <div class="threadlinks">
+ <a href="#{boards_url}#{board}/">■Volver al BBS■</a>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/">Hilo completo</a>
+ <?py if prevrange: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/#{prevrange}">Anteriores 100</a>
+ <?py #endif ?>
+ <?py if nextrange: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/#{nextrange}">Próximos 100</a>
+ <?py #endif ?>
+ <?py if thread['length'] > 51: ?>
+ <a href="#{boards_url}#{board}/read/#{thread['timestamp']}/l50">Últimos 50</a>
+ <?py #endif ?>
+ <a href="#top">&#9650;Subir&#9650;</a>
+ </div>
+ <input type="hidden" name="board" value="#{board}" /><input type="hidden" name="parent" value="#{thread['id']}" /><input type="hidden" name="password" value="" />
+ <?py if thread['locked'] != '1': ?>
+ <div style="display:none"><input type="text" name="name" size="13" /> <input type="text" name="email" size="13" /></div>
+ <span><input type="submit" value="Responder" accesskey="z" /> <input type="button" name="preview" value="Previsualizar" /></span> <span><span>Nombre:&nbsp;</span><input type="text" name="fielda" size="13" accesskey="n" /><span>&nbsp;E-mail:&nbsp;</span><input type="text" name="fieldb" size="13" accesskey="e" /></span><br />
+ <textarea name="message" cols="80" rows="7" accesskey="m"></textarea><br />
+ <div id="preview#{thread['id']}" class="msg" style="display:none"></div>
+ <?py if allow_image_replies: ?>
+ <input type="file" name="file" />
+ <?py #endif ?>
+ <?py #endif ?>
+</form>
+<?py #endfor ?>
+<?py #endif ?>
+<div class="end">weabot.py ver <?py include('templates/revision.html') ?> Bienvenido a Internet BBS/IB</div>
+<a name="bottom"></a>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/templates/txt_threadlist.html b/cgi/templates/txt_threadlist.html
new file mode 100644
index 0000000..bb09df4
--- /dev/null
+++ b/cgi/templates/txt_threadlist.html
@@ -0,0 +1,67 @@
+<?py include('templates/txt_base_top.html') ?>
+<body class="threads" data-brd="#{board}">
+<div id="main_nav"><a href="/" target="_top">Bienvenido a Internet</a> | <?py include('templates/navbar.html') ?></div>
+<?py if banner_url: ?>
+ <img class="banner" src="#{banner_url}" style="width:#{banner_width}px;height:#{banner_height}px;" />
+<?py #endif ?>
+<div id="titlebox" class="outerbox">
+ <div class="innerbox"><h1>#{board_long}</h1></div>
+ <div class="innerbox links"><b>¿Eres nuevo?</b> <a href="/guia.html"><b>C&oacute;mo postear</b></a> | <a href="/faq.html"><b>Preguntas frecuentes</b></a> | <a href="/bai/"><b>Contacto</b></a>
+ <?py if not force_css: ?>| <b>Apariencia:</b>
+ <?py for title in txt_styles: ?><a href="#" class="ss">#{title}</a> <?py #endfor ?>
+ <?py #endif ?></div>
+</div>
+<a name="menu"></a>
+<div id="threadbox" class="outerbox"><div class="innerbox">
+ <div id="threadlinks"><a href="#{boards_url}#{board}/"><b>Volver al BBS</b></a> <a href="/#{board}/kako/"><b>Ver hilos archivados</b></a> <a href="#newthread"><b>Crear nuevo hilo</b></a></div>
+ <div id="listmenu">Orden: <a class="l_s" href="#">Normal</a> <a class="l_s" href="#">Edad</a> <a class="l_s" href="#">Largo</a> <a class="l_s" href="#">Rapidez</a> <a class="l_s" href="#">Aleatorio</a> / Modo: <a class="l_d" href="#">Lista</a> <a class="l_d" href="#">Malla</a> / Buscar: <input id="l_sr" style="padding:0px;width:100px;" type="text"></div>
+</div></div>
+<div id="content" class="list">
+<div id="header" class="row">
+ <div>#</div>
+ <div style="width:100%;">Asunto</div>
+ <div>Resp.</div>
+ <div class="hdate">Última respuesta</div>
+</div>
+<?py iter = 1 ?>
+<?py for thread in more_threads: ?>
+<div class="row">
+ <div class="pos">#{iter}:</div>
+ <div class="thread"><a href="#{boards_url}#{board}/read/#{thread['timestamp']}/${'l50' if int(thread['length']) > 50 else ''}">#{thread["subject"]}</a></div>
+ <div class="com">#{thread["length"]}</div>
+ <div class="date" data-unix="#{timestamps[iter-1][0]}">#{timestamps[iter-1][1]}</div>
+</div>
+<?py iter += 1 ?>
+<?py #endfor ?>
+</div>
+<a name="newthread"></a>
+<div id="createbox" class="outerbox">
+ <div class="extrabox"></div>
+ <div class="innerbox">
+ <h5>Formulario de nuevo hilo</h5>
+ <form id="postform0" action="#{cgi_url}post" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="board" value="#{board}" /><input type="hidden" name="password" value="" />
+ <table style="max-width:600px;">
+ <tr>
+ <td style="text-align:right;">Asunto:</td>
+ <td colspan="3" style="width:100%;"><input type="text" name="subject" size="50" maxlength="100" /></td>
+ <td><input type="submit" value="Crear nuevo hilo" /></td>
+ </tr>
+ <tr>
+ <td style="text-align:right;">Nombre:</td><td><input type="text" name="fielda" /></td>
+ <td style="text-align:right;">E-mail:</td><td><input type="text" name="fieldb" /></td>
+ <td><input type="button" name="preview" value="Previsualizar" /></td>
+ </tr>
+ <tr id="options" style="display:none;"><td></td><td colspan="4"><div id="preview0" class="msg"></div></td></tr>
+ <tr><td style="text-align:right;">Mensaje:</td><td colspan="4"><textarea name="message" cols="70" rows="10"></textarea></td></tr>
+ <?py if allow_images: ?>
+ <tr><td style="text-align:right;">Archivo:</td><td colspan="4"><input type="file" name="file" /></td></tr>
+ <?py #endif ?>
+ </table>
+ <div style="display:none;">Trampa: <input type="text" name="name" maxlength="50" /> <input type="text" name="email" maxlength="50" /></div>
+ </form>
+ </div>
+</div>
+<center id="footer"><a href="/" target="_top">Bienvenido a Internet BBS/IB</a> weabot.py <?py include('templates/revision.html') ?> + FastCGI + tenjin<br />No se ponga sensible, baisano...</center>
+</body>
+</html> \ No newline at end of file
diff --git a/cgi/tenjin.py b/cgi/tenjin.py
new file mode 100644
index 0000000..db8cdde
--- /dev/null
+++ b/cgi/tenjin.py
@@ -0,0 +1,2118 @@
+##
+## $Release: 1.1.1 $
+## $Copyright: copyright(c) 2007-2012 kuwata-lab.com all rights reserved. $
+## $License: MIT License $
+##
+## Permission is hereby granted, free of charge, to any person obtaining
+## a copy of this software and associated documentation files (the
+## "Software"), to deal in the Software without restriction, including
+## without limitation the rights to use, copy, modify, merge, publish,
+## distribute, sublicense, and/or sell copies of the Software, and to
+## permit persons to whom the Software is furnished to do so, subject to
+## the following conditions:
+##
+## The above copyright notice and this permission notice shall be
+## included in all copies or substantial portions of the Software.
+##
+## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+## NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+## LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+## OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+## WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+##
+
+"""Very fast and light-weight template engine based embedded Python.
+ See User's Guide and examples for details.
+ http://www.kuwata-lab.com/tenjin/pytenjin-users-guide.html
+ http://www.kuwata-lab.com/tenjin/pytenjin-examples.html
+"""
+
+__version__ = "$Release: 1.1.1 $"[10:-2]
+__license__ = "$License: MIT License $"[10:-2]
+__all__ = ('Template', 'Engine', )
+
+
+import sys, os, re, time, marshal
+from time import time as _time
+from os.path import getmtime as _getmtime
+from os.path import isfile as _isfile
+random = pickle = unquote = None # lazy import
+python3 = sys.version_info[0] == 3
+python2 = sys.version_info[0] == 2
+
+logger = None
+
+
+##
+## utilities
+##
+
+def _write_binary_file(filename, content):
+ global random
+ if random is None: from random import random
+ tmpfile = filename + str(random())[1:]
+ f = open(tmpfile, 'w+b') # on windows, 'w+b' is preffered than 'wb'
+ try:
+ f.write(content)
+ finally:
+ f.close()
+ if os.path.exists(tmpfile):
+ try:
+ os.rename(tmpfile, filename)
+ except:
+ os.remove(filename) # on windows, existing file should be removed before renaming
+ os.rename(tmpfile, filename)
+
+def _read_binary_file(filename):
+ f = open(filename, 'rb')
+ try:
+ return f.read()
+ finally:
+ f.close()
+
+codecs = None # lazy import
+
+def _read_text_file(filename, encoding=None):
+ global codecs
+ if not codecs: import codecs
+ f = codecs.open(filename, encoding=(encoding or 'utf-8'))
+ try:
+ return f.read()
+ finally:
+ f.close()
+
+def _read_template_file(filename, encoding=None):
+ s = _read_binary_file(filename) ## binary(=str)
+ if encoding: s = s.decode(encoding) ## binary(=str) to unicode
+ return s
+
+_basestring = basestring
+_unicode = unicode
+_bytes = str
+
+def _ignore_not_found_error(f, default=None):
+ try:
+ return f()
+ except OSError, ex:
+ if ex.errno == 2: # error: No such file or directory
+ return default
+ raise
+
+def create_module(module_name, dummy_func=None, **kwargs):
+ """ex. mod = create_module('tenjin.util')"""
+ try:
+ mod = type(sys)(module_name)
+ except:
+ # The module creation above does not work for Jython 2.5.2
+ import imp
+ mod = imp.new_module(module_name)
+
+ mod.__file__ = __file__
+ mod.__dict__.update(kwargs)
+ sys.modules[module_name] = mod
+ if dummy_func:
+ exec(dummy_func.func_code, mod.__dict__)
+ return mod
+
+def _raise(exception_class, *args):
+ raise exception_class(*args)
+
+
+##
+## helper method's module
+##
+
+def _dummy():
+ global unquote
+ unquote = None
+ global to_str, escape, echo, new_cycle, generate_tostrfunc
+ global start_capture, stop_capture, capture_as, captured_as, CaptureContext
+ global _p, _P, _decode_params
+
+ def generate_tostrfunc(encode=None, decode=None):
+ """Generate 'to_str' function with encode or decode encoding.
+ ex. generate to_str() function which encodes unicode into binary(=str).
+ to_str = tenjin.generate_tostrfunc(encode='utf-8')
+ repr(to_str(u'hoge')) #=> 'hoge' (str)
+ ex. generate to_str() function which decodes binary(=str) into unicode.
+ to_str = tenjin.generate_tostrfunc(decode='utf-8')
+ repr(to_str('hoge')) #=> u'hoge' (unicode)
+ """
+ if encode:
+ if decode:
+ raise ValueError("can't specify both encode and decode encoding.")
+ else:
+ def to_str(val, _str=str, _unicode=unicode, _isa=isinstance, _encode=encode):
+ """Convert val into string or return '' if None. Unicode will be encoded into binary(=str)."""
+ if _isa(val, _str): return val
+ if val is None: return ''
+ #if _isa(val, _unicode): return val.encode(_encode) # unicode to binary(=str)
+ if _isa(val, _unicode):
+ return val.encode(_encode) # unicode to binary(=str)
+ return _str(val)
+ else:
+ if decode:
+ def to_str(val, _str=str, _unicode=unicode, _isa=isinstance, _decode=decode):
+ """Convert val into string or return '' if None. Binary(=str) will be decoded into unicode."""
+ #if _isa(val, _str): return val.decode(_decode) # binary(=str) to unicode
+ if _isa(val, _str):
+ return val.decode(_decode)
+ if val is None: return ''
+ if _isa(val, _unicode): return val
+ return _unicode(val)
+ else:
+ def to_str(val, _str=str, _unicode=unicode, _isa=isinstance):
+ """Convert val into string or return '' if None. Both binary(=str) and unicode will be retruned as-is."""
+ if _isa(val, _str): return val
+ if val is None: return ''
+ if _isa(val, _unicode): return val
+ return _str(val)
+ return to_str
+
+ to_str = generate_tostrfunc(encode='utf-8') # or encode=None?
+
+ def echo(string):
+ """add string value into _buf. this is equivarent to '#{string}'."""
+ lvars = sys._getframe(1).f_locals # local variables
+ lvars['_buf'].append(string)
+
+ def new_cycle(*values):
+ """Generate cycle object.
+ ex.
+ cycle = new_cycle('odd', 'even')
+ print(cycle()) #=> 'odd'
+ print(cycle()) #=> 'even'
+ print(cycle()) #=> 'odd'
+ print(cycle()) #=> 'even'
+ """
+ def gen(values):
+ i, n = 0, len(values)
+ while True:
+ yield values[i]
+ i = (i + 1) % n
+ return gen(values).next
+
+ class CaptureContext(object):
+
+ def __init__(self, name, store_to_context=True, lvars=None):
+ self.name = name
+ self.store_to_context = store_to_context
+ self.lvars = lvars or sys._getframe(1).f_locals
+
+ def __enter__(self):
+ lvars = self.lvars
+ self._buf_orig = lvars['_buf']
+ lvars['_buf'] = _buf = []
+ lvars['_extend'] = _buf.extend
+ return self
+
+ def __exit__(self, *args):
+ lvars = self.lvars
+ _buf = lvars['_buf']
+ lvars['_buf'] = self._buf_orig
+ lvars['_extend'] = self._buf_orig.extend
+ lvars[self.name] = self.captured = ''.join(_buf)
+ if self.store_to_context and '_context' in lvars:
+ lvars['_context'][self.name] = self.captured
+
+ def __iter__(self):
+ self.__enter__()
+ yield self
+ self.__exit__()
+
+ def start_capture(varname=None, _depth=1):
+ """(obsolete) start capturing with name."""
+ lvars = sys._getframe(_depth).f_locals
+ capture_context = CaptureContext(varname, None, lvars)
+ lvars['_capture_context'] = capture_context
+ capture_context.__enter__()
+
+ def stop_capture(store_to_context=True, _depth=1):
+ """(obsolete) stop capturing and return the result of capturing.
+ if store_to_context is True then the result is stored into _context[varname].
+ """
+ lvars = sys._getframe(_depth).f_locals
+ capture_context = lvars.pop('_capture_context', None)
+ if not capture_context:
+ raise Exception('stop_capture(): start_capture() is not called before.')
+ capture_context.store_to_context = store_to_context
+ capture_context.__exit__()
+ return capture_context.captured
+
+ def capture_as(name, store_to_context=True):
+ """capture partial of template."""
+ return CaptureContext(name, store_to_context, sys._getframe(1).f_locals)
+
+ def captured_as(name, _depth=1):
+ """helper method for layout template.
+ if captured string is found then append it to _buf and return True,
+ else return False.
+ """
+ lvars = sys._getframe(_depth).f_locals # local variables
+ if name in lvars:
+ _buf = lvars['_buf']
+ _buf.append(lvars[name])
+ return True
+ return False
+
+ def _p(arg):
+ """ex. '/show/'+_p("item['id']") => "/show/#{item['id']}" """
+ return '<`#%s#`>' % arg # decoded into #{...} by preprocessor
+
+ def _P(arg):
+ """ex. '<b>%s</b>' % _P("item['id']") => "<b>${item['id']}</b>" """
+ return '<`$%s$`>' % arg # decoded into ${...} by preprocessor
+
+ def _decode_params(s):
+ """decode <`#...#`> and <`$...$`> into #{...} and ${...}"""
+ global unquote
+ if unquote is None:
+ from urllib import unquote
+ dct = { 'lt':'<', 'gt':'>', 'amp':'&', 'quot':'"', '#039':"'", }
+ def unescape(s):
+ #return s.replace('&lt;', '<').replace('&gt;', '>').replace('&quot;', '"').replace('&#039;', "'").replace('&amp;', '&')
+ return re.sub(r'&(lt|gt|quot|amp|#039);', lambda m: dct[m.group(1)], s)
+ s = to_str(s)
+ s = re.sub(r'%3C%60%23(.*?)%23%60%3E', lambda m: '#{%s}' % unquote(m.group(1)), s)
+ s = re.sub(r'%3C%60%24(.*?)%24%60%3E', lambda m: '${%s}' % unquote(m.group(1)), s)
+ s = re.sub(r'&lt;`#(.*?)#`&gt;', lambda m: '#{%s}' % unescape(m.group(1)), s)
+ s = re.sub(r'&lt;`\$(.*?)\$`&gt;', lambda m: '${%s}' % unescape(m.group(1)), s)
+ s = re.sub(r'<`#(.*?)#`>', r'#{\1}', s)
+ s = re.sub(r'<`\$(.*?)\$`>', r'${\1}', s)
+ return s
+
+helpers = create_module('tenjin.helpers', _dummy, sys=sys, re=re)
+helpers.__all__ = ['to_str', 'escape', 'echo', 'new_cycle', 'generate_tostrfunc',
+ 'start_capture', 'stop_capture', 'capture_as', 'captured_as',
+ 'not_cached', 'echo_cached', 'cache_as',
+ '_p', '_P', '_decode_params',
+ ]
+generate_tostrfunc = helpers.generate_tostrfunc
+
+
+##
+## escaped module
+##
+def _dummy():
+ global is_escaped, as_escaped, to_escaped
+ global Escaped, EscapedStr, EscapedUnicode
+ global __all__
+ __all__ = ('is_escaped', 'as_escaped', 'to_escaped', ) #'Escaped', 'EscapedStr',
+
+ class Escaped(object):
+ """marking class that object is already escaped."""
+ pass
+
+ def is_escaped(value):
+ """return True if value is marked as escaped, else return False."""
+ return isinstance(value, Escaped)
+
+ class EscapedStr(str, Escaped):
+ """string class which is marked as escaped."""
+ pass
+
+ class EscapedUnicode(unicode, Escaped):
+ """unicode class which is marked as escaped."""
+ pass
+
+ def as_escaped(s):
+ """mark string as escaped, without escaping."""
+ if isinstance(s, str): return EscapedStr(s)
+ if isinstance(s, unicode): return EscapedUnicode(s)
+ raise TypeError("as_escaped(%r): expected str or unicode." % (s, ))
+
+ def to_escaped(value):
+ """convert any value into string and escape it.
+ if value is already marked as escaped, don't escape it."""
+ if hasattr(value, '__html__'):
+ value = value.__html__()
+ if is_escaped(value):
+ #return value # EscapedUnicode should be convered into EscapedStr
+ return as_escaped(_helpers.to_str(value))
+ #if isinstance(value, _basestring):
+ # return as_escaped(_helpers.escape(value))
+ return as_escaped(_helpers.escape(_helpers.to_str(value)))
+
+escaped = create_module('tenjin.escaped', _dummy, _helpers=helpers)
+
+
+##
+## module for html
+##
+def _dummy():
+ global escape_html, escape_xml, escape, tagattr, tagattrs, _normalize_attrs
+ global checked, selected, disabled, nl2br, text2html, nv, js_link
+
+ #_escape_table = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
+ #_escape_pattern = re.compile(r'[&<>"]')
+ ##_escape_callable = lambda m: _escape_table[m.group(0)]
+ ##_escape_callable = lambda m: _escape_table.__get__(m.group(0))
+ #_escape_get = _escape_table.__getitem__
+ #_escape_callable = lambda m: _escape_get(m.group(0))
+ #_escape_sub = _escape_pattern.sub
+
+ #def escape_html(s):
+ # return s # 3.02
+
+ #def escape_html(s):
+ # return _escape_pattern.sub(_escape_callable, s) # 6.31
+
+ #def escape_html(s):
+ # return _escape_sub(_escape_callable, s) # 6.01
+
+ #def escape_html(s, _p=_escape_pattern, _f=_escape_callable):
+ # return _p.sub(_f, s) # 6.27
+
+ #def escape_html(s, _sub=_escape_pattern.sub, _callable=_escape_callable):
+ # return _sub(_callable, s) # 6.04
+
+ #def escape_html(s):
+ # s = s.replace('&', '&amp;')
+ # s = s.replace('<', '&lt;')
+ # s = s.replace('>', '&gt;')
+ # s = s.replace('"', '&quot;')
+ # return s # 5.83
+
+ def escape_html(s):
+ """Escape '&', '<', '>', '"' into '&amp;', '&lt;', '&gt;', '&quot;'."""
+ return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;').replace("'", '&#39;') # 5.72
+
+ escape_xml = escape_html # for backward compatibility
+
+ def tagattr(name, expr, value=None, escape=True):
+ """(experimental) Return ' name="value"' if expr is true value, else '' (empty string).
+ If value is not specified, expr is used as value instead."""
+ if not expr and expr != 0: return _escaped.as_escaped('')
+ if value is None: value = expr
+ if escape: value = _escaped.to_escaped(value)
+ return _escaped.as_escaped(' %s="%s"' % (name, value))
+
+ def tagattrs(**kwargs):
+ """(experimental) built html tag attribtes.
+ ex.
+ >>> tagattrs(klass='main', size=20)
+ ' class="main" size="20"'
+ >>> tagattrs(klass='', size=0)
+ ''
+ """
+ kwargs = _normalize_attrs(kwargs)
+ esc = _escaped.to_escaped
+ s = ''.join([ ' %s="%s"' % (k, esc(v)) for k, v in kwargs.iteritems() if v or v == 0 ])
+ return _escaped.as_escaped(s)
+
+ def _normalize_attrs(kwargs):
+ if 'klass' in kwargs: kwargs['class'] = kwargs.pop('klass')
+ if 'checked' in kwargs: kwargs['checked'] = kwargs.pop('checked') and 'checked' or None
+ if 'selected' in kwargs: kwargs['selected'] = kwargs.pop('selected') and 'selected' or None
+ if 'disabled' in kwargs: kwargs['disabled'] = kwargs.pop('disabled') and 'disabled' or None
+ return kwargs
+
+ def checked(expr):
+ """return ' checked="checked"' if expr is true."""
+ return _escaped.as_escaped(expr and ' checked="checked"' or '')
+
+ def selected(expr):
+ """return ' selected="selected"' if expr is true."""
+ return _escaped.as_escaped(expr and ' selected="selected"' or '')
+
+ def disabled(expr):
+ """return ' disabled="disabled"' if expr is true."""
+ return _escaped.as_escaped(expr and ' disabled="disabled"' or '')
+
+ def nl2br(text):
+ """replace "\n" to "<br />\n" and return it."""
+ if not text:
+ return _escaped.as_escaped('')
+ return _escaped.as_escaped(text.replace('\n', '<br />\n'))
+
+ def text2html(text, use_nbsp=True):
+ """(experimental) escape xml characters, replace "\n" to "<br />\n", and return it."""
+ if not text:
+ return _escaped.as_escaped('')
+ s = _escaped.to_escaped(text)
+ if use_nbsp: s = s.replace(' ', ' &nbsp;')
+ #return nl2br(s)
+ s = s.replace('\n', '<br />\n')
+ return _escaped.as_escaped(s)
+
+ def nv(name, value, sep=None, **kwargs):
+ """(experimental) Build name and value attributes.
+ ex.
+ >>> nv('rank', 'A')
+ 'name="rank" value="A"'
+ >>> nv('rank', 'A', '.')
+ 'name="rank" value="A" id="rank.A"'
+ >>> nv('rank', 'A', '.', checked=True)
+ 'name="rank" value="A" id="rank.A" checked="checked"'
+ >>> nv('rank', 'A', '.', klass='error', style='color:red')
+ 'name="rank" value="A" id="rank.A" class="error" style="color:red"'
+ """
+ name = _escaped.to_escaped(name)
+ value = _escaped.to_escaped(value)
+ s = sep and 'name="%s" value="%s" id="%s"' % (name, value, name+sep+value) \
+ or 'name="%s" value="%s"' % (name, value)
+ html = kwargs and s + tagattrs(**kwargs) or s
+ return _escaped.as_escaped(html)
+
+ def js_link(label, onclick, **kwargs):
+ s = kwargs and tagattrs(**kwargs) or ''
+ html = '<a href="javascript:undefined" onclick="%s;return false"%s>%s</a>' % \
+ (_escaped.to_escaped(onclick), s, _escaped.to_escaped(label))
+ return _escaped.as_escaped(html)
+
+html = create_module('tenjin.html', _dummy, helpers=helpers, _escaped=escaped)
+helpers.escape = html.escape_html
+helpers.html = html # for backward compatibility
+sys.modules['tenjin.helpers.html'] = html
+
+
+##
+## utility function to set default encoding of template files
+##
+_template_encoding = (None, 'utf-8') # encodings for decode and encode
+
+def set_template_encoding(decode=None, encode=None):
+ """Set default encoding of template files.
+ This should be called before importing helper functions.
+ ex.
+ ## I like template files to be unicode-base like Django.
+ import tenjin
+ tenjin.set_template_encoding('utf-8') # should be called before importing helpers
+ from tenjin.helpers import *
+ """
+ global _template_encoding
+ if _template_encoding == (decode, encode):
+ return
+ if decode and encode:
+ raise ValueError("set_template_encoding(): cannot specify both decode and encode.")
+ if not decode and not encode:
+ raise ValueError("set_template_encoding(): decode or encode should be specified.")
+ if decode:
+ Template.encoding = decode # unicode base template
+ helpers.to_str = helpers.generate_tostrfunc(decode=decode)
+ else:
+ Template.encoding = None # binary base template
+ helpers.to_str = helpers.generate_tostrfunc(encode=encode)
+ _template_encoding = (decode, encode)
+
+
+##
+## Template class
+##
+
+class TemplateSyntaxError(SyntaxError):
+
+ def build_error_message(self):
+ ex = self
+ if not ex.text:
+ return self.args[0]
+ return ''.join([
+ "%s:%s:%s: %s\n" % (ex.filename, ex.lineno, ex.offset, ex.msg, ),
+ "%4d: %s\n" % (ex.lineno, ex.text.rstrip(), ),
+ " %s^\n" % (' ' * ex.offset, ),
+ ])
+
+
+class Template(object):
+ """Convert and evaluate embedded python string.
+ See User's Guide and examples for details.
+ http://www.kuwata-lab.com/tenjin/pytenjin-users-guide.html
+ http://www.kuwata-lab.com/tenjin/pytenjin-examples.html
+ """
+
+ ## default value of attributes
+ filename = None
+ encoding = None
+ escapefunc = 'escape'
+ tostrfunc = 'to_str'
+ indent = 4
+ preamble = None # "_buf = []; _expand = _buf.expand; _to_str = to_str; _escape = escape"
+ postamble = None # "print ''.join(_buf)"
+ smarttrim = None
+ args = None
+ timestamp = None
+ trace = False # if True then '<!-- begin: file -->' and '<!-- end: file -->' are printed
+
+ def __init__(self, filename=None, encoding=None, input=None, escapefunc=None, tostrfunc=None,
+ indent=None, preamble=None, postamble=None, smarttrim=None, trace=None):
+ """Initailizer of Template class.
+
+ filename:str (=None)
+ Filename to convert (optional). If None, no convert.
+ encoding:str (=None)
+ Encoding name. If specified, template string is converted into
+ unicode object internally.
+ Template.render() returns str object if encoding is None,
+ else returns unicode object if encoding name is specified.
+ input:str (=None)
+ Input string. In other words, content of template file.
+ Template file will not be read if this argument is specified.
+ escapefunc:str (='escape')
+ Escape function name.
+ tostrfunc:str (='to_str')
+ 'to_str' function name.
+ indent:int (=4)
+ Indent width.
+ preamble:str or bool (=None)
+ Preamble string which is inserted into python code.
+ If true, '_buf = []; ' is used insated.
+ postamble:str or bool (=None)
+ Postamble string which is appended to python code.
+ If true, 'print("".join(_buf))' is used instead.
+ smarttrim:bool (=None)
+ If True then "<div>\\n#{_context}\\n</div>" is parsed as
+ "<div>\\n#{_context}</div>".
+ """
+ if encoding is not None: self.encoding = encoding
+ if escapefunc is not None: self.escapefunc = escapefunc
+ if tostrfunc is not None: self.tostrfunc = tostrfunc
+ if indent is not None: self.indent = indent
+ if preamble is not None: self.preamble = preamble
+ if postamble is not None: self.postamble = postamble
+ if smarttrim is not None: self.smarttrim = smarttrim
+ if trace is not None: self.trace = trace
+ #
+ if preamble is True: self.preamble = "_buf = []"
+ if postamble is True: self.postamble = "print(''.join(_buf))"
+ if input:
+ self.convert(input, filename)
+ self.timestamp = False # False means 'file not exist' (= Engine should not check timestamp of file)
+ elif filename:
+ self.convert_file(filename)
+ else:
+ self._reset()
+
+ def _reset(self, input=None, filename=None):
+ self.script = None
+ self.bytecode = None
+ self.input = input
+ self.filename = filename
+ if input != None:
+ i = input.find("\n")
+ if i < 0:
+ self.newline = "\n" # or None
+ elif len(input) >= 2 and input[i-1] == "\r":
+ self.newline = "\r\n"
+ else:
+ self.newline = "\n"
+ self._localvars_assignments_added = False
+
+ def _localvars_assignments(self):
+ return "_extend=_buf.extend;_to_str=%s;_escape=%s; " % (self.tostrfunc, self.escapefunc)
+
+ def before_convert(self, buf):
+ if self.preamble:
+ eol = self.input.startswith('<?py') and "\n" or "; "
+ buf.append(self.preamble + eol)
+
+ def after_convert(self, buf):
+ if self.postamble:
+ if buf and not buf[-1].endswith("\n"):
+ buf.append("\n")
+ buf.append(self.postamble + "\n")
+
+ def convert_file(self, filename):
+ """Convert file into python script and return it.
+ This is equivarent to convert(open(filename).read(), filename).
+ """
+ input = _read_template_file(filename)
+ return self.convert(input, filename)
+
+ def convert(self, input, filename=None):
+ """Convert string in which python code is embedded into python script and return it.
+
+ input:str
+ Input string to convert into python code.
+ filename:str (=None)
+ Filename of input. this is optional but recommended to report errors.
+ """
+ if self.encoding and isinstance(input, str):
+ input = input.decode(self.encoding)
+ self._reset(input, filename)
+ buf = []
+ self.before_convert(buf)
+ self.parse_stmts(buf, input)
+ self.after_convert(buf)
+ script = ''.join(buf)
+ self.script = script
+ return script
+
+ STMT_PATTERN = (r'<\?py( |\t|\r?\n)(.*?) ?\?>([ \t]*\r?\n)?', re.S)
+
+ def stmt_pattern(self):
+ pat = self.STMT_PATTERN
+ if isinstance(pat, tuple):
+ pat = self.__class__.STMT_PATTERN = re.compile(*pat)
+ return pat
+
+ def parse_stmts(self, buf, input):
+ if not input: return
+ rexp = self.stmt_pattern()
+ is_bol = True
+ index = 0
+ for m in rexp.finditer(input):
+ mspace, code, rspace = m.groups()
+ #mspace, close, rspace = m.groups()
+ #code = input[m.start()+4+len(mspace):m.end()-len(close)-(rspace and len(rspace) or 0)]
+ text = input[index:m.start()]
+ index = m.end()
+ ## detect spaces at beginning of line
+ lspace = None
+ if text == '':
+ if is_bol:
+ lspace = ''
+ elif text[-1] == '\n':
+ lspace = ''
+ else:
+ rindex = text.rfind('\n')
+ if rindex < 0:
+ if is_bol and text.isspace():
+ lspace, text = text, ''
+ else:
+ s = text[rindex+1:]
+ if s.isspace():
+ lspace, text = s, text[:rindex+1]
+ #is_bol = rspace is not None
+ ## add text, spaces, and statement
+ self.parse_exprs(buf, text, is_bol)
+ is_bol = rspace is not None
+ #if mspace == "\n":
+ if mspace and mspace.endswith("\n"):
+ code = "\n" + (code or "")
+ #if rspace == "\n":
+ if rspace and rspace.endswith("\n"):
+ code = (code or "") + "\n"
+ if code:
+ code = self.statement_hook(code)
+ m = self._match_to_args_declaration(code)
+ if m:
+ self._add_args_declaration(buf, m)
+ else:
+ self.add_stmt(buf, code)
+ rest = input[index:]
+ if rest:
+ self.parse_exprs(buf, rest)
+ self._arrange_indent(buf)
+
+ def statement_hook(self, stmt):
+ """expand macros and parse '#@ARGS' in a statement."""
+ return stmt.replace("\r\n", "\n") # Python can't handle "\r\n" in code
+
+ def _match_to_args_declaration(self, stmt):
+ if self.args is not None:
+ return None
+ args_pattern = r'^ *#@ARGS(?:[ \t]+(.*?))?$'
+ return re.match(args_pattern, stmt)
+
+ def _add_args_declaration(self, buf, m):
+ arr = (m.group(1) or '').split(',')
+ args = []; declares = []
+ for s in arr:
+ arg = s.strip()
+ if not s: continue
+ if not re.match('^[a-zA-Z_]\w*$', arg):
+ raise ValueError("%r: invalid template argument." % arg)
+ args.append(arg)
+ declares.append("%s = _context.get('%s'); " % (arg, arg))
+ self.args = args
+ #nl = stmt[m.end():]
+ #if nl: declares.append(nl)
+ buf.append(''.join(declares) + "\n")
+
+ s = '(?:\{.*?\}.*?)*'
+ EXPR_PATTERN = (r'#\{(.*?'+s+r')\}|\$\{(.*?'+s+r')\}|\{=(?:=(.*?)=|(.*?))=\}', re.S)
+ del s
+
+ def expr_pattern(self):
+ pat = self.EXPR_PATTERN
+ if isinstance(pat, tuple):
+ self.__class__.EXPR_PATTERN = pat = re.compile(*pat)
+ return pat
+
+ def get_expr_and_flags(self, match):
+ expr1, expr2, expr3, expr4 = match.groups()
+ if expr1 is not None: return expr1, (False, True) # not escape, call to_str
+ if expr2 is not None: return expr2, (True, True) # call escape, call to_str
+ if expr3 is not None: return expr3, (False, True) # not escape, call to_str
+ if expr4 is not None: return expr4, (True, True) # call escape, call to_str
+
+ def parse_exprs(self, buf, input, is_bol=False):
+ buf2 = []
+ self._parse_exprs(buf2, input, is_bol)
+ if buf2:
+ buf.append(''.join(buf2))
+
+ def _parse_exprs(self, buf, input, is_bol=False):
+ if not input: return
+ self.start_text_part(buf)
+ rexp = self.expr_pattern()
+ smarttrim = self.smarttrim
+ nl = self.newline
+ nl_len = len(nl)
+ pos = 0
+ for m in rexp.finditer(input):
+ start = m.start()
+ text = input[pos:start]
+ pos = m.end()
+ expr, flags = self.get_expr_and_flags(m)
+ #
+ if text:
+ self.add_text(buf, text)
+ self.add_expr(buf, expr, *flags)
+ #
+ if smarttrim:
+ flag_bol = text.endswith(nl) or not text and (start > 0 or is_bol)
+ if flag_bol and not flags[0] and input[pos:pos+nl_len] == nl:
+ pos += nl_len
+ buf.append("\n")
+ if smarttrim:
+ if buf and buf[-1] == "\n":
+ buf.pop()
+ rest = input[pos:]
+ if rest:
+ self.add_text(buf, rest, True)
+ self.stop_text_part(buf)
+ if input[-1] == '\n':
+ buf.append("\n")
+
+ def start_text_part(self, buf):
+ self._add_localvars_assignments_to_text(buf)
+ #buf.append("_buf.extend((")
+ buf.append("_extend((")
+
+ def _add_localvars_assignments_to_text(self, buf):
+ if not self._localvars_assignments_added:
+ self._localvars_assignments_added = True
+ buf.append(self._localvars_assignments())
+
+ def stop_text_part(self, buf):
+ buf.append("));")
+
+ def _quote_text(self, text):
+ text = re.sub(r"(['\\\\])", r"\\\1", text)
+ text = text.replace("\r\n", "\\r\n")
+ return text
+
+ def add_text(self, buf, text, encode_newline=False):
+ if not text: return
+ use_unicode = self.encoding and python2
+ buf.append(use_unicode and "u'''" or "'''")
+ text = self._quote_text(text)
+ if not encode_newline: buf.extend((text, "''', "))
+ elif text.endswith("\r\n"): buf.extend((text[0:-2], "\\r\\n''', "))
+ elif text.endswith("\n"): buf.extend((text[0:-1], "\\n''', "))
+ else: buf.extend((text, "''', "))
+
+ _add_text = add_text
+
+ def add_expr(self, buf, code, *flags):
+ if not code or code.isspace(): return
+ flag_escape, flag_tostr = flags
+ if not self.tostrfunc: flag_tostr = False
+ if not self.escapefunc: flag_escape = False
+ if flag_tostr and flag_escape: s1, s2 = "_escape(_to_str(", ")), "
+ elif flag_tostr: s1, s2 = "_to_str(", "), "
+ elif flag_escape: s1, s2 = "_escape(", "), "
+ else: s1, s2 = "(", "), "
+ buf.extend((s1, code, s2, ))
+
+ def add_stmt(self, buf, code):
+ if not code: return
+ lines = code.splitlines(True) # keep "\n"
+ if lines[-1][-1] != "\n":
+ lines[-1] = lines[-1] + "\n"
+ buf.extend(lines)
+ self._add_localvars_assignments_to_stmts(buf)
+
+ def _add_localvars_assignments_to_stmts(self, buf):
+ if self._localvars_assignments_added:
+ return
+ for index, stmt in enumerate(buf):
+ if not re.match(r'^[ \t]*(?:\#|_buf ?= ?\[\]|from __future__)', stmt):
+ break
+ else:
+ return
+ self._localvars_assignments_added = True
+ if re.match(r'^[ \t]*(if|for|while|def|with|class)\b', stmt):
+ buf.insert(index, self._localvars_assignments() + "\n")
+ else:
+ buf[index] = self._localvars_assignments() + buf[index]
+
+
+ _START_WORDS = dict.fromkeys(('for', 'if', 'while', 'def', 'try:', 'with', 'class'), True)
+ _END_WORDS = dict.fromkeys(('#end', '#endfor', '#endif', '#endwhile', '#enddef', '#endtry', '#endwith', '#endclass'), True)
+ _CONT_WORDS = dict.fromkeys(('elif', 'else:', 'except', 'except:', 'finally:'), True)
+ _WORD_REXP = re.compile(r'\S+')
+
+ depth = -1
+
+ ##
+ ## ex.
+ ## input = r"""
+ ## if items:
+ ## _buf.extend(('<ul>\n', ))
+ ## i = 0
+ ## for item in items:
+ ## i += 1
+ ## _buf.extend(('<li>', to_str(item), '</li>\n', ))
+ ## #endfor
+ ## _buf.extend(('</ul>\n', ))
+ ## #endif
+ ## """[1:]
+ ## lines = input.splitlines(True)
+ ## block = self.parse_lines(lines)
+ ## #=> [ "if items:\n",
+ ## [ "_buf.extend(('<ul>\n', ))\n",
+ ## "i = 0\n",
+ ## "for item in items:\n",
+ ## [ "i += 1\n",
+ ## "_buf.extend(('<li>', to_str(item), '</li>\n', ))\n",
+ ## ],
+ ## "#endfor\n",
+ ## "_buf.extend(('</ul>\n', ))\n",
+ ## ],
+ ## "#endif\n",
+ ## ]
+ def parse_lines(self, lines):
+ block = []
+ try:
+ self._parse_lines(lines.__iter__(), False, block, 0)
+ except StopIteration:
+ if self.depth > 0:
+ fname, linenum, colnum, linetext = self.filename, len(lines), None, None
+ raise TemplateSyntaxError("unexpected EOF.", (fname, linenum, colnum, linetext))
+ else:
+ pass
+ return block
+
+ def _parse_lines(self, lines_iter, end_block, block, linenum):
+ if block is None: block = []
+ _START_WORDS = self._START_WORDS
+ _END_WORDS = self._END_WORDS
+ _CONT_WORDS = self._CONT_WORDS
+ _WORD_REXP = self._WORD_REXP
+ get_line = lines_iter.next
+ while True:
+ line = get_line()
+ linenum += line.count("\n")
+ m = _WORD_REXP.search(line)
+ if not m:
+ block.append(line)
+ continue
+ word = m.group(0)
+ if word in _END_WORDS:
+ if word != end_block and word != '#end':
+ if end_block is False:
+ msg = "'%s' found but corresponding statement is missing." % (word, )
+ else:
+ msg = "'%s' expected but got '%s'." % (end_block, word)
+ colnum = m.start() + 1
+ raise TemplateSyntaxError(msg, (self.filename, linenum, colnum, line))
+ return block, line, None, linenum
+ elif line.endswith(':\n') or line.endswith(':\r\n'):
+ if word in _CONT_WORDS:
+ return block, line, word, linenum
+ elif word in _START_WORDS:
+ block.append(line)
+ self.depth += 1
+ cont_word = None
+ try:
+ child_block, line, cont_word, linenum = \
+ self._parse_lines(lines_iter, '#end'+word, [], linenum)
+ block.extend((child_block, line, ))
+ while cont_word: # 'elif' or 'else:'
+ child_block, line, cont_word, linenum = \
+ self._parse_lines(lines_iter, '#end'+word, [], linenum)
+ block.extend((child_block, line, ))
+ except StopIteration:
+ msg = "'%s' is not closed." % (cont_word or word)
+ colnum = m.start() + 1
+ raise TemplateSyntaxError(msg, (self.filename, linenum, colnum, line))
+ self.depth -= 1
+ else:
+ block.append(line)
+ else:
+ block.append(line)
+ assert "unreachable"
+
+ def _join_block(self, block, buf, depth):
+ indent = ' ' * (self.indent * depth)
+ for line in block:
+ if isinstance(line, list):
+ self._join_block(line, buf, depth+1)
+ elif line.isspace():
+ buf.append(line)
+ else:
+ buf.append(indent + line.lstrip())
+
+ def _arrange_indent(self, buf):
+ """arrange indentation of statements in buf"""
+ block = self.parse_lines(buf)
+ buf[:] = []
+ self._join_block(block, buf, 0)
+
+
+ def render(self, context=None, globals=None, _buf=None):
+ """Evaluate python code with context dictionary.
+ If _buf is None then return the result of evaluation as str,
+ else return None.
+
+ context:dict (=None)
+ Context object to evaluate. If None then new dict is created.
+ globals:dict (=None)
+ Global object. If None then globals() is used.
+ _buf:list (=None)
+ If None then new list is created.
+ """
+ if context is None:
+ locals = context = {}
+ elif self.args is None:
+ locals = context.copy()
+ else:
+ locals = {}
+ if '_engine' in context:
+ context.get('_engine').hook_context(locals)
+ locals['_context'] = context
+ if globals is None:
+ globals = sys._getframe(1).f_globals
+ bufarg = _buf
+ if _buf is None:
+ _buf = []
+ locals['_buf'] = _buf
+ if not self.bytecode:
+ self.compile()
+ if self.trace:
+ _buf.append("<!-- ***** begin: %s ***** -->\n" % self.filename)
+ exec(self.bytecode, globals, locals)
+ _buf.append("<!-- ***** end: %s ***** -->\n" % self.filename)
+ else:
+ exec(self.bytecode, globals, locals)
+ if bufarg is not None:
+ return bufarg
+ elif not logger:
+ return ''.join(_buf)
+ else:
+ try:
+ return ''.join(_buf)
+ except UnicodeDecodeError, ex:
+ logger.error("[tenjin.Template] " + str(ex))
+ logger.error("[tenjin.Template] (_buf=%r)" % (_buf, ))
+ raise
+
+ def compile(self):
+ """compile self.script into self.bytecode"""
+ self.bytecode = compile(self.script, self.filename or '(tenjin)', 'exec')
+
+
+##
+## preprocessor class
+##
+
+class Preprocessor(Template):
+ """Template class for preprocessing."""
+
+ STMT_PATTERN = (r'<\?PY( |\t|\r?\n)(.*?) ?\?>([ \t]*\r?\n)?', re.S)
+
+ EXPR_PATTERN = (r'#\{\{(.*?)\}\}|\$\{\{(.*?)\}\}|\{#=(?:=(.*?)=|(.*?))=#\}', re.S)
+
+ def add_expr(self, buf, code, *flags):
+ if not code or code.isspace():
+ return
+ code = "_decode_params(%s)" % code
+ Template.add_expr(self, buf, code, *flags)
+
+
+class TemplatePreprocessor(object):
+ factory = Preprocessor
+
+ def __init__(self, factory=None):
+ if factory is not None: self.factory = factory
+ self.globals = sys._getframe(1).f_globals
+
+ def __call__(self, input, **kwargs):
+ filename = kwargs.get('filename')
+ context = kwargs.get('context') or {}
+ globals = kwargs.get('globals') or self.globals
+ template = self.factory()
+ template.convert(input, filename)
+ return template.render(context, globals=globals)
+
+
+class TrimPreprocessor(object):
+
+ _rexp = re.compile(r'^[ \t]+<', re.M)
+ _rexp_all = re.compile(r'^[ \t]+', re.M)
+
+ def __init__(self, all=False):
+ self.all = all
+
+ def __call__(self, input, **kwargs):
+ if self.all:
+ return self._rexp_all.sub('', input)
+ else:
+ return self._rexp.sub('<', input)
+
+
+class PrefixedLinePreprocessor(object):
+
+ def __init__(self, prefix='::(?=[ \t]|$)'):
+ self.prefix = prefix
+ self.regexp = re.compile(r'^([ \t]*)' + prefix + r'(.*)', re.M)
+
+ def convert_prefixed_lines(self, text):
+ fn = lambda m: "%s<?py%s ?>" % (m.group(1), m.group(2))
+ return self.regexp.sub(fn, text)
+
+ STMT_REXP = re.compile(r'<\?py\s.*?\?>', re.S)
+
+ def __call__(self, input, **kwargs):
+ buf = []; append = buf.append
+ pos = 0
+ for m in self.STMT_REXP.finditer(input):
+ text = input[pos:m.start()]
+ stmt = m.group(0)
+ pos = m.end()
+ if text: append(self.convert_prefixed_lines(text))
+ append(stmt)
+ rest = input[pos:]
+ if rest: append(self.convert_prefixed_lines(rest))
+ return "".join(buf)
+
+
+class ParseError(Exception):
+ pass
+
+
+class JavaScriptPreprocessor(object):
+
+ def __init__(self, **attrs):
+ self._attrs = attrs
+
+ def __call__(self, input, **kwargs):
+ return self.parse(input, kwargs.get('filename'))
+
+ def parse(self, input, filename=None):
+ buf = []
+ self._parse_chunks(input, buf, filename)
+ return ''.join(buf)
+
+ CHUNK_REXP = re.compile(r'(?:^( *)<|<)!-- *#(?:JS: (\$?\w+(?:\.\w+)*\(.*?\))|/JS:?) *-->([ \t]*\r?\n)?', re.M)
+
+ def _scan_chunks(self, input, filename):
+ rexp = self.CHUNK_REXP
+ pos = 0
+ curr_funcdecl = None
+ for m in rexp.finditer(input):
+ lspace, funcdecl, rspace = m.groups()
+ text = input[pos:m.start()]
+ pos = m.end()
+ if funcdecl:
+ if curr_funcdecl:
+ raise ParseError("%s is nested in %s. (file: %s, line: %s)" % \
+ (funcdecl, curr_funcdecl, filename, _linenum(input, m.start()), ))
+ curr_funcdecl = funcdecl
+ else:
+ if not curr_funcdecl:
+ raise ParseError("unexpected '<!-- #/JS -->'. (file: %s, line: %s)" % \
+ (filename, _linenum(input, m.start()), ))
+ curr_funcdecl = None
+ yield text, lspace, funcdecl, rspace, False
+ if curr_funcdecl:
+ raise ParseError("%s is not closed by '<!-- #/JS -->'. (file: %s, line: %s)" % \
+ (curr_funcdecl, filename, _linenum(input, m.start()), ))
+ rest = input[pos:]
+ yield rest, None, None, None, True
+
+ def _parse_chunks(self, input, buf, filename=None):
+ if not input: return
+ stag = '<script'
+ if self._attrs:
+ for k in self._attrs:
+ stag = "".join((stag, ' ', k, '="', self._attrs[k], '"'))
+ stag += '>'
+ etag = '</script>'
+ for text, lspace, funcdecl, rspace, end_p in self._scan_chunks(input, filename):
+ if end_p: break
+ if funcdecl:
+ buf.append(text)
+ if re.match(r'^\$?\w+\(', funcdecl):
+ buf.extend((lspace or '', stag, 'function ', funcdecl, "{var _buf='';", rspace or ''))
+ else:
+ m = re.match(r'(.+?)\((.*)\)', funcdecl)
+ buf.extend((lspace or '', stag, m.group(1), '=function(', m.group(2), "){var _buf='';", rspace or ''))
+ else:
+ self._parse_stmts(text, buf)
+ buf.extend((lspace or '', "return _buf;};", etag, rspace or ''))
+ #
+ buf.append(text)
+
+ STMT_REXP = re.compile(r'(?:^( *)<|<)\?js(\s.*?) ?\?>([ \t]*\r?\n)?', re.M | re.S)
+
+ def _scan_stmts(self, input):
+ rexp = self.STMT_REXP
+ pos = 0
+ for m in rexp.finditer(input):
+ lspace, code, rspace = m.groups()
+ text = input[pos:m.start()]
+ pos = m.end()
+ yield text, lspace, code, rspace, False
+ rest = input[pos:]
+ yield rest, None, None, None, True
+
+ def _parse_stmts(self, input, buf):
+ if not input: return
+ for text, lspace, code, rspace, end_p in self._scan_stmts(input):
+ if end_p: break
+ if lspace is not None and rspace is not None:
+ self._parse_exprs(text, buf)
+ buf.extend((lspace, code, rspace))
+ else:
+ if lspace:
+ text += lspace
+ self._parse_exprs(text, buf)
+ buf.append(code)
+ if rspace:
+ self._parse_exprs(rspace, buf)
+ if text:
+ self._parse_exprs(text, buf)
+
+ s = r'(?:\{[^{}]*?\}[^{}]*?)*'
+ EXPR_REXP = re.compile(r'\{=(.*?)=\}|([$#])\{(.*?' + s + r')\}', re.S)
+ del s
+
+ def _get_expr(self, m):
+ code1, ch, code2 = m.groups()
+ if ch:
+ code = code2
+ escape_p = ch == '$'
+ elif code1[0] == code1[-1] == '=':
+ code = code1[1:-1]
+ escape_p = False
+ else:
+ code = code1
+ escape_p = True
+ return code, escape_p
+
+ def _scan_exprs(self, input):
+ rexp = self.EXPR_REXP
+ pos = 0
+ for m in rexp.finditer(input):
+ text = input[pos:m.start()]
+ pos = m.end()
+ code, escape_p = self._get_expr(m)
+ yield text, code, escape_p, False
+ rest = input[pos:]
+ yield rest, None, None, True
+
+ def _parse_exprs(self, input, buf):
+ if not input: return
+ buf.append("_buf+=")
+ extend = buf.extend
+ op = ''
+ for text, code, escape_p, end_p in self._scan_exprs(input):
+ if end_p:
+ break
+ if text:
+ extend((op, self._escape_text(text)))
+ op = '+'
+ if code:
+ extend((op, escape_p and '_E(' or '_S(', code, ')'))
+ op = '+'
+ rest = text
+ if rest:
+ extend((op, self._escape_text(rest)))
+ if input.endswith("\n"):
+ buf.append(";\n")
+ else:
+ buf.append(";")
+
+ def _escape_text(self, text):
+ lines = text.splitlines(True)
+ fn = self._escape_str
+ s = "\\\n".join( fn(line) for line in lines )
+ return "".join(("'", s, "'"))
+
+ def _escape_str(self, string):
+ return string.replace("\\", "\\\\").replace("'", "\\'").replace("\n", r"\n")
+
+
+def _linenum(input, pos):
+ return input[0:pos].count("\n") + 1
+
+
+JS_FUNC = r"""
+function _S(x){return x==null?'':x;}
+function _E(x){return x==null?'':typeof(x)!=='string'?x:x.replace(/[&<>"']/g,_EF);}
+var _ET={'&':"&amp;",'<':"&lt;",'>':"&gt;",'"':"&quot;","'":"&#039;"};
+function _EF(c){return _ET[c];};
+"""[1:-1]
+JS_FUNC = escaped.EscapedStr(JS_FUNC)
+
+
+
+##
+## cache storages
+##
+
+class CacheStorage(object):
+ """[abstract] Template object cache class (in memory and/or file)"""
+
+ def __init__(self):
+ self.items = {} # key: full path, value: template object
+
+ def get(self, cachepath, create_template):
+ """get template object. if not found, load attributes from cache file and restore template object."""
+ template = self.items.get(cachepath)
+ if not template:
+ dct = self._load(cachepath)
+ if dct:
+ template = create_template()
+ for k in dct:
+ setattr(template, k, dct[k])
+ self.items[cachepath] = template
+ return template
+
+ def set(self, cachepath, template):
+ """set template object and save template attributes into cache file."""
+ self.items[cachepath] = template
+ dct = self._save_data_of(template)
+ return self._store(cachepath, dct)
+
+ def _save_data_of(self, template):
+ return { 'args' : template.args, 'bytecode' : template.bytecode,
+ 'script': template.script, 'timestamp': template.timestamp }
+
+ def unset(self, cachepath):
+ """remove template object from dict and cache file."""
+ self.items.pop(cachepath, None)
+ return self._delete(cachepath)
+
+ def clear(self):
+ """remove all template objects and attributes from dict and cache file."""
+ d, self.items = self.items, {}
+ for k in d.iterkeys():
+ self._delete(k)
+ d.clear()
+
+ def _load(self, cachepath):
+ """(abstract) load dict object which represents template object attributes from cache file."""
+ raise NotImplementedError.new("%s#_load(): not implemented yet." % self.__class__.__name__)
+
+ def _store(self, cachepath, template):
+ """(abstract) load dict object which represents template object attributes from cache file."""
+ raise NotImplementedError.new("%s#_store(): not implemented yet." % self.__class__.__name__)
+
+ def _delete(self, cachepath):
+ """(abstract) remove template object from cache file."""
+ raise NotImplementedError.new("%s#_delete(): not implemented yet." % self.__class__.__name__)
+
+
+class MemoryCacheStorage(CacheStorage):
+
+ def _load(self, cachepath):
+ return None
+
+ def _store(self, cachepath, template):
+ pass
+
+ def _delete(self, cachepath):
+ pass
+
+
+class FileCacheStorage(CacheStorage):
+
+ def _load(self, cachepath):
+ if not _isfile(cachepath): return None
+ if logger: logger.info("[tenjin.%s] load cache (file=%r)" % (self.__class__.__name__, cachepath))
+ data = _read_binary_file(cachepath)
+ return self._restore(data)
+
+ def _store(self, cachepath, dct):
+ if logger: logger.info("[tenjin.%s] store cache (file=%r)" % (self.__class__.__name__, cachepath))
+ data = self._dump(dct)
+ _write_binary_file(cachepath, data)
+
+ def _restore(self, data):
+ raise NotImplementedError("%s._restore(): not implemented yet." % self.__class__.__name__)
+
+ def _dump(self, dct):
+ raise NotImplementedError("%s._dump(): not implemented yet." % self.__class__.__name__)
+
+ def _delete(self, cachepath):
+ _ignore_not_found_error(lambda: os.unlink(cachepath))
+
+
+class MarshalCacheStorage(FileCacheStorage):
+
+ def _restore(self, data):
+ return marshal.loads(data)
+
+ def _dump(self, dct):
+ return marshal.dumps(dct)
+
+
+class PickleCacheStorage(FileCacheStorage):
+
+ def __init__(self, *args, **kwargs):
+ global pickle
+ if pickle is None:
+ import cPickle as pickle
+ FileCacheStorage.__init__(self, *args, **kwargs)
+
+ def _restore(self, data):
+ return pickle.loads(data)
+
+ def _dump(self, dct):
+ dct.pop('bytecode', None)
+ return pickle.dumps(dct)
+
+
+class TextCacheStorage(FileCacheStorage):
+
+ def _restore(self, data):
+ header, script = data.split("\n\n", 1)
+ timestamp = encoding = args = None
+ for line in header.split("\n"):
+ key, val = line.split(": ", 1)
+ if key == 'timestamp': timestamp = float(val)
+ elif key == 'encoding': encoding = val
+ elif key == 'args': args = val.split(', ')
+ if encoding: script = script.decode(encoding) ## binary(=str) to unicode
+ return {'args': args, 'script': script, 'timestamp': timestamp}
+
+ def _dump(self, dct):
+ s = dct['script']
+ if dct.get('encoding') and isinstance(s, unicode):
+ s = s.encode(dct['encoding']) ## unicode to binary(=str)
+ sb = []
+ sb.append("timestamp: %s\n" % dct['timestamp'])
+ if dct.get('encoding'):
+ sb.append("encoding: %s\n" % dct['encoding'])
+ if dct.get('args') is not None:
+ sb.append("args: %s\n" % ', '.join(dct['args']))
+ sb.append("\n")
+ sb.append(s)
+ s = ''.join(sb)
+ if python3:
+ if isinstance(s, str):
+ s = s.encode(dct.get('encoding') or 'utf-8') ## unicode(=str) to binary
+ return s
+
+ def _save_data_of(self, template):
+ dct = FileCacheStorage._save_data_of(self, template)
+ dct['encoding'] = template.encoding
+ return dct
+
+
+
+##
+## abstract class for data cache
+##
+class KeyValueStore(object):
+
+ def get(self, key, *options):
+ raise NotImplementedError("%s.get(): not implemented yet." % self.__class__.__name__)
+
+ def set(self, key, value, *options):
+ raise NotImplementedError("%s.set(): not implemented yet." % self.__class__.__name__)
+
+ def delete(self, key, *options):
+ raise NotImplementedError("%s.del(): not implemented yet." % self.__class__.__name__)
+
+ def has(self, key, *options):
+ raise NotImplementedError("%s.has(): not implemented yet." % self.__class__.__name__)
+
+
+##
+## memory base data cache
+##
+class MemoryBaseStore(KeyValueStore):
+
+ def __init__(self):
+ self.values = {}
+
+ def get(self, key, original_timestamp=None):
+ tupl = self.values.get(key)
+ if not tupl:
+ return None
+ value, created_at, expires_at = tupl
+ if original_timestamp is not None and created_at < original_timestamp:
+ self.delete(key)
+ return None
+ if expires_at < _time():
+ self.delete(key)
+ return None
+ return value
+
+ def set(self, key, value, lifetime=0):
+ created_at = _time()
+ expires_at = lifetime and created_at + lifetime or 0
+ self.values[key] = (value, created_at, expires_at)
+ return True
+
+ def delete(self, key):
+ try:
+ del self.values[key]
+ return True
+ except KeyError:
+ return False
+
+ def has(self, key):
+ pair = self.values.get(key)
+ if not pair:
+ return False
+ value, created_at, expires_at = pair
+ if expires_at and expires_at < _time():
+ self.delete(key)
+ return False
+ return True
+
+
+##
+## file base data cache
+##
+class FileBaseStore(KeyValueStore):
+
+ lifetime = 604800 # = 60*60*24*7
+
+ def __init__(self, root_path, encoding=None):
+ if not os.path.isdir(root_path):
+ raise ValueError("%r: directory not found." % (root_path, ))
+ self.root_path = root_path
+ if encoding is None and python3:
+ encoding = 'utf-8'
+ self.encoding = encoding
+
+ _pat = re.compile(r'[^-.\/\w]')
+
+ def filepath(self, key, _pat1=_pat):
+ return os.path.join(self.root_path, _pat1.sub('_', key))
+
+ def get(self, key, original_timestamp=None):
+ fpath = self.filepath(key)
+ #if not _isfile(fpath): return None
+ stat = _ignore_not_found_error(lambda: os.stat(fpath), None)
+ if stat is None:
+ return None
+ created_at = stat.st_ctime
+ expires_at = stat.st_mtime
+ if original_timestamp is not None and created_at < original_timestamp:
+ self.delete(key)
+ return None
+ if expires_at < _time():
+ self.delete(key)
+ return None
+ if self.encoding:
+ f = lambda: _read_text_file(fpath, self.encoding)
+ else:
+ f = lambda: _read_binary_file(fpath)
+ return _ignore_not_found_error(f, None)
+
+ def set(self, key, value, lifetime=0):
+ fpath = self.filepath(key)
+ dirname = os.path.dirname(fpath)
+ if not os.path.isdir(dirname):
+ os.makedirs(dirname)
+ now = _time()
+ if isinstance(value, _unicode):
+ value = value.encode(self.encoding or 'utf-8')
+ _write_binary_file(fpath, value)
+ expires_at = now + (lifetime or self.lifetime) # timestamp
+ os.utime(fpath, (expires_at, expires_at))
+ return True
+
+ def delete(self, key):
+ fpath = self.filepath(key)
+ ret = _ignore_not_found_error(lambda: os.unlink(fpath), False)
+ return ret != False
+
+ def has(self, key):
+ fpath = self.filepath(key)
+ if not _isfile(fpath):
+ return False
+ if _getmtime(fpath) < _time():
+ self.delete(key)
+ return False
+ return True
+
+
+
+##
+## html fragment cache helper class
+##
+class FragmentCacheHelper(object):
+ """html fragment cache helper class."""
+
+ lifetime = 60 # 1 minute
+ prefix = None
+
+ def __init__(self, store, lifetime=None, prefix=None):
+ self.store = store
+ if lifetime is not None: self.lifetime = lifetime
+ if prefix is not None: self.prefix = prefix
+
+ def not_cached(self, cache_key, lifetime=None):
+ """(obsolete. use cache_as() instead of this.)
+ html fragment cache helper. see document of FragmentCacheHelper class."""
+ context = sys._getframe(1).f_locals['_context']
+ context['_cache_key'] = cache_key
+ key = self.prefix and self.prefix + cache_key or cache_key
+ value = self.store.get(key)
+ if value: ## cached
+ if logger: logger.debug('[tenjin.not_cached] %r: cached.' % (cache_key, ))
+ context[key] = value
+ return False
+ else: ## not cached
+ if logger: logger.debug('[tenjin.not_cached]: %r: not cached.' % (cache_key, ))
+ if key in context: del context[key]
+ if lifetime is None: lifetime = self.lifetime
+ context['_cache_lifetime'] = lifetime
+ helpers.start_capture(cache_key, _depth=2)
+ return True
+
+ def echo_cached(self):
+ """(obsolete. use cache_as() instead of this.)
+ html fragment cache helper. see document of FragmentCacheHelper class."""
+ f_locals = sys._getframe(1).f_locals
+ context = f_locals['_context']
+ cache_key = context.pop('_cache_key')
+ key = self.prefix and self.prefix + cache_key or cache_key
+ if key in context: ## cached
+ value = context.pop(key)
+ else: ## not cached
+ value = helpers.stop_capture(False, _depth=2)
+ lifetime = context.pop('_cache_lifetime')
+ self.store.set(key, value, lifetime)
+ f_locals['_buf'].append(value)
+
+ def functions(self):
+ """(obsolete. use cache_as() instead of this.)"""
+ return (self.not_cached, self.echo_cached)
+
+ def cache_as(self, cache_key, lifetime=None):
+ key = self.prefix and self.prefix + cache_key or cache_key
+ _buf = sys._getframe(1).f_locals['_buf']
+ value = self.store.get(key)
+ if value:
+ if logger: logger.debug('[tenjin.cache_as] %r: cache found.' % (cache_key, ))
+ _buf.append(value)
+ else:
+ if logger: logger.debug('[tenjin.cache_as] %r: expired or not cached yet.' % (cache_key, ))
+ _buf_len = len(_buf)
+ yield None
+ value = ''.join(_buf[_buf_len:])
+ self.store.set(key, value, lifetime)
+
+## you can change default store by 'tenjin.helpers.fragment_cache.store = ...'
+helpers.fragment_cache = FragmentCacheHelper(MemoryBaseStore())
+helpers.not_cached = helpers.fragment_cache.not_cached
+helpers.echo_cached = helpers.fragment_cache.echo_cached
+helpers.cache_as = helpers.fragment_cache.cache_as
+helpers.__all__.extend(('not_cached', 'echo_cached', 'cache_as'))
+
+
+
+##
+## helper class to find and read template
+##
+class Loader(object):
+
+ def exists(self, filepath):
+ raise NotImplementedError("%s.exists(): not implemented yet." % self.__class__.__name__)
+
+ def find(self, filename, dirs=None):
+ #: if dirs provided then search template file from it.
+ if dirs:
+ for dirname in dirs:
+ filepath = os.path.join(dirname, filename)
+ if self.exists(filepath):
+ return filepath
+ #: if dirs not provided then just return filename if file exists.
+ else:
+ if self.exists(filename):
+ return filename
+ #: if file not found then return None.
+ return None
+
+ def abspath(self, filename):
+ raise NotImplementedError("%s.abspath(): not implemented yet." % self.__class__.__name__)
+
+ def timestamp(self, filepath):
+ raise NotImplementedError("%s.timestamp(): not implemented yet." % self.__class__.__name__)
+
+ def load(self, filepath):
+ raise NotImplementedError("%s.timestamp(): not implemented yet." % self.__class__.__name__)
+
+
+
+##
+## helper class to find and read files
+##
+class FileSystemLoader(Loader):
+
+ def exists(self, filepath):
+ #: return True if filepath exists as a file.
+ return os.path.isfile(filepath)
+
+ def abspath(self, filepath):
+ #: return full-path of filepath
+ return os.path.abspath(filepath)
+
+ def timestamp(self, filepath):
+ #: return mtime of file
+ return _getmtime(filepath)
+
+ def load(self, filepath):
+ #: if file exists, return file content and mtime
+ def f():
+ mtime = _getmtime(filepath)
+ input = _read_template_file(filepath)
+ mtime2 = _getmtime(filepath)
+ if mtime != mtime2:
+ mtime = mtime2
+ input = _read_template_file(filepath)
+ mtime2 = _getmtime(filepath)
+ if mtime != mtime2:
+ if logger:
+ logger.warn("[tenjin] %s.load(): timestamp is changed while reading file." % self.__class__.__name__)
+ return input, mtime
+ #: if file not exist, return None
+ return _ignore_not_found_error(f)
+
+
+##
+##
+##
+class TemplateNotFoundError(Exception):
+ pass
+
+
+
+##
+## template engine class
+##
+
+class Engine(object):
+ """Template Engine class.
+ See User's Guide and examples for details.
+ http://www.kuwata-lab.com/tenjin/pytenjin-users-guide.html
+ http://www.kuwata-lab.com/tenjin/pytenjin-examples.html
+ """
+
+ ## default value of attributes
+ prefix = ''
+ postfix = ''
+ layout = None
+ templateclass = Template
+ path = None
+ cache = TextCacheStorage() # save converted Python code into text file
+ lang = None
+ loader = FileSystemLoader()
+ preprocess = False
+ preprocessorclass = Preprocessor
+ timestamp_interval = 1 # seconds
+
+ def __init__(self, prefix=None, postfix=None, layout=None, path=None, cache=True, preprocess=None, templateclass=None, preprocessorclass=None, lang=None, loader=None, pp=None, **kwargs):
+ """Initializer of Engine class.
+
+ prefix:str (='')
+ Prefix string used to convert template short name to template filename.
+ postfix:str (='')
+ Postfix string used to convert template short name to template filename.
+ layout:str (=None)
+ Default layout template name.
+ path:list of str(=None)
+ List of directory names which contain template files.
+ cache:bool or CacheStorage instance (=True)
+ Cache storage object to store converted python code.
+ If True, default cache storage (=Engine.cache) is used (if it is None
+ then create MarshalCacheStorage object for each engine object).
+ If False, no cache storage is used nor no cache files are created.
+ preprocess:bool(=False)
+ Activate preprocessing or not.
+ templateclass:class (=Template)
+ Template class which engine creates automatically.
+ lang:str (=None)
+ Language name such as 'en', 'fr', 'ja', and so on. If you specify
+ this, cache file path will be 'inex.html.en.cache' for example.
+ pp:list (=None)
+ List of preprocessor object which is callable and manipulates template content.
+ kwargs:dict
+ Options for Template class constructor.
+ See document of Template.__init__() for details.
+ """
+ if prefix: self.prefix = prefix
+ if postfix: self.postfix = postfix
+ if layout: self.layout = layout
+ if templateclass: self.templateclass = templateclass
+ if preprocessorclass: self.preprocessorclass = preprocessorclass
+ if path is not None: self.path = path
+ if lang is not None: self.lang = lang
+ if loader is not None: self.loader = loader
+ if preprocess is not None: self.preprocess = preprocess
+ if pp is None: pp = []
+ elif isinstance(pp, list): pass
+ elif isinstance(pp, tuple): pp = list(pp)
+ else:
+ raise TypeError("'pp' expected to be a list but got %r." % (pp,))
+ self.pp = pp
+ if preprocess:
+ self.pp.append(TemplatePreprocessor(self.preprocessorclass))
+ self.kwargs = kwargs
+ self.encoding = kwargs.get('encoding')
+ self._filepaths = {} # template_name => relative path and absolute path
+ self._added_templates = {} # templates added by add_template()
+ #self.cache = cache
+ self._set_cache_storage(cache)
+
+ def _set_cache_storage(self, cache):
+ if cache is True:
+ if not self.cache:
+ self.cache = MarshalCacheStorage()
+ elif cache is None:
+ pass
+ elif cache is False:
+ self.cache = None
+ elif isinstance(cache, CacheStorage):
+ self.cache = cache
+ else:
+ raise ValueError("%r: invalid cache object." % (cache, ))
+
+ def cachename(self, filepath):
+ #: if lang is provided then add it to cache filename.
+ if self.lang:
+ return '%s.%s.cache' % (filepath, self.lang)
+ #: return cache file name.
+ else:
+ return filepath + '.cache'
+
+ def to_filename(self, template_name):
+ """Convert template short name into filename.
+ ex.
+ >>> engine = tenjin.Engine(prefix='user_', postfix='.pyhtml')
+ >>> engine.to_filename(':list')
+ 'user_list.pyhtml'
+ >>> engine.to_filename('list')
+ 'list'
+ """
+ #: if template_name starts with ':', add prefix and postfix to it.
+ if template_name[0] == ':' :
+ return self.prefix + template_name[1:] + self.postfix
+ #: if template_name doesn't start with ':', just return it.
+ return template_name
+
+ def _create_template(self, input=None, filepath=None, _context=None, _globals=None):
+ #: if input is not specified then just create empty template object.
+ template = self.templateclass(None, **self.kwargs)
+ #: if input is specified then create template object and return it.
+ if input:
+ template.convert(input, filepath)
+ return template
+
+ def _preprocess(self, input, filepath, _context, _globals):
+ #if _context is None: _context = {}
+ #if _globals is None: _globals = sys._getframe(3).f_globals
+ #: preprocess template and return result
+ #preprocessor = self.preprocessorclass(filepath, input=input)
+ #return preprocessor.render(_context, globals=_globals)
+ #: preprocesses input with _context and returns result.
+ if '_engine' not in _context:
+ self.hook_context(_context)
+ for pp in self.pp:
+ input = pp.__call__(input, filename=filepath, context=_context, globals=_globals)
+ return input
+
+ def add_template(self, template):
+ self._added_templates[template.filename] = template
+
+ def _get_template_from_cache(self, cachepath, filepath):
+ #: if template not found in cache, return None
+ template = self.cache.get(cachepath, self.templateclass)
+ if not template:
+ return None
+ assert template.timestamp is not None
+ #: if checked within a sec, skip timestamp check.
+ now = _time()
+ last_checked = getattr(template, '_last_checked_at', None)
+ if last_checked and now < last_checked + self.timestamp_interval:
+ #if logger: logger.trace('[tenjin.%s] timestamp check skipped (%f < %f + %f)' % \
+ # (self.__class__.__name__, now, template._last_checked_at, self.timestamp_interval))
+ return template
+ #: if timestamp of template objectis same as file, return it.
+ if template.timestamp == self.loader.timestamp(filepath):
+ template._last_checked_at = now
+ return template
+ #: if timestamp of template object is different from file, clear it
+ #cache._delete(cachepath)
+ if logger: logger.info("[tenjin.%s] cache expired (filepath=%r)" % \
+ (self.__class__.__name__, filepath))
+ return None
+
+ def get_template(self, template_name, _context=None, _globals=None):
+ """Return template object.
+ If template object has not registered, template engine creates
+ and registers template object automatically.
+ """
+ #: accept template_name such as ':index'.
+ filename = self.to_filename(template_name)
+ #: if template object is added by add_template(), return it.
+ if filename in self._added_templates:
+ return self._added_templates[filename]
+ #: get filepath and fullpath of template
+ pair = self._filepaths.get(filename)
+ if pair:
+ filepath, fullpath = pair
+ else:
+ #: if template file is not found then raise TemplateNotFoundError.
+ filepath = self.loader.find(filename, self.path)
+ if not filepath:
+ raise TemplateNotFoundError('%s: filename not found (path=%r).' % (filename, self.path))
+ #
+ fullpath = self.loader.abspath(filepath)
+ self._filepaths[filename] = (filepath, fullpath)
+ #: use full path as base of cache file path
+ cachepath = self.cachename(fullpath)
+ #: get template object from cache
+ cache = self.cache
+ template = cache and self._get_template_from_cache(cachepath, filepath) or None
+ #: if template object is not found in cache or is expired...
+ if not template:
+ ret = self.loader.load(filepath)
+ if not ret:
+ raise TemplateNotFoundError("%r: template not found." % filepath)
+ input, timestamp = ret
+ if self.pp: ## required for preprocessing
+ if _context is None: _context = {}
+ if _globals is None: _globals = sys._getframe(1).f_globals
+ input = self._preprocess(input, filepath, _context, _globals)
+ #: create template object.
+ template = self._create_template(input, filepath, _context, _globals)
+ #: set timestamp and filename of template object.
+ template.timestamp = timestamp
+ template._last_checked_at = _time()
+ #: save template object into cache.
+ if cache:
+ if not template.bytecode:
+ #: ignores syntax error when compiling.
+ try: template.compile()
+ except SyntaxError: pass
+ cache.set(cachepath, template)
+ #else:
+ # template.compile()
+ #:
+ template.filename = filepath
+ return template
+
+ def include(self, template_name, append_to_buf=True, **kwargs):
+ """Evaluate template using current local variables as context.
+
+ template_name:str
+ Filename (ex. 'user_list.pyhtml') or short name (ex. ':list') of template.
+ append_to_buf:boolean (=True)
+ If True then append output into _buf and return None,
+ else return stirng output.
+
+ ex.
+ <?py include('file.pyhtml') ?>
+ #{include('file.pyhtml', False)}
+ <?py val = include('file.pyhtml', False) ?>
+ """
+ #: get local and global vars of caller.
+ frame = sys._getframe(1)
+ locals = frame.f_locals
+ globals = frame.f_globals
+ #: get _context from caller's local vars.
+ assert '_context' in locals
+ context = locals['_context']
+ #: if kwargs specified then add them into context.
+ if kwargs:
+ context.update(kwargs)
+ #: get template object with context data and global vars.
+ ## (context and globals are passed to get_template() only for preprocessing.)
+ template = self.get_template(template_name, context, globals)
+ #: if append_to_buf is true then add output to _buf.
+ #: if append_to_buf is false then don't add output to _buf.
+ if append_to_buf: _buf = locals['_buf']
+ else: _buf = None
+ #: render template and return output.
+ s = template.render(context, globals, _buf=_buf)
+ #: kwargs are removed from context data.
+ if kwargs:
+ for k in kwargs:
+ del context[k]
+ return s
+
+ def render(self, template_name, context=None, globals=None, layout=True):
+ """Evaluate template with layout file and return result of evaluation.
+
+ template_name:str
+ Filename (ex. 'user_list.pyhtml') or short name (ex. ':list') of template.
+ context:dict (=None)
+ Context object to evaluate. If None then new dict is used.
+ globals:dict (=None)
+ Global context to evaluate. If None then globals() is used.
+ layout:str or Bool(=True)
+ If True, the default layout name specified in constructor is used.
+ If False, no layout template is used.
+ If str, it is regarded as layout template name.
+
+ If temlate object related with the 'template_name' argument is not exist,
+ engine generates a template object and register it automatically.
+ """
+ if context is None:
+ context = {}
+ if globals is None:
+ globals = sys._getframe(1).f_globals
+ self.hook_context(context)
+ while True:
+ ## context and globals are passed to get_template() only for preprocessing
+ template = self.get_template(template_name, context, globals)
+ content = template.render(context, globals)
+ layout = context.pop('_layout', layout)
+ if layout is True or layout is None:
+ layout = self.layout
+ if not layout:
+ break
+ template_name = layout
+ layout = False
+ context['_content'] = content
+ context.pop('_content', None)
+ return content
+
+ def hook_context(self, context):
+ #: add engine itself into context data.
+ context['_engine'] = self
+ #context['render'] = self.render
+ #: add include() method into context data.
+ context['include'] = self.include
+
+
+##
+## safe template and engine
+##
+
+class SafeTemplate(Template):
+ """Uses 'to_escaped()' instead of 'escape()'.
+ '#{...}' is not allowed with this class. Use '[==...==]' instead.
+ """
+
+ tostrfunc = 'to_str'
+ escapefunc = 'to_escaped'
+
+ def get_expr_and_flags(self, match):
+ return _get_expr_and_flags(match, "#{%s}: '#{}' is not allowed with SafeTemplate.")
+
+
+class SafePreprocessor(Preprocessor):
+
+ tostrfunc = 'to_str'
+ escapefunc = 'to_escaped'
+
+ def get_expr_and_flags(self, match):
+ return _get_expr_and_flags(match, "#{{%s}}: '#{{}}' is not allowed with SafePreprocessor.")
+
+
+def _get_expr_and_flags(match, errmsg):
+ expr1, expr2, expr3, expr4 = match.groups()
+ if expr1 is not None:
+ raise TemplateSyntaxError(errmsg % match.group(1))
+ if expr2 is not None: return expr2, (True, False) # #{...} : call escape, not to_str
+ if expr3 is not None: return expr3, (False, True) # [==...==] : not escape, call to_str
+ if expr4 is not None: return expr4, (True, False) # [=...=] : call escape, not to_str
+
+
+class SafeEngine(Engine):
+
+ templateclass = SafeTemplate
+ preprocessorclass = SafePreprocessor
+
+
+##
+## for Google App Engine
+## (should separate into individual file or module?)
+##
+
+def _dummy():
+ global memcache, _tenjin
+ memcache = _tenjin = None # lazy import of google.appengine.api.memcache
+ global GaeMemcacheCacheStorage, GaeMemcacheStore, init
+
+ class GaeMemcacheCacheStorage(CacheStorage):
+
+ lifetime = 0 # 0 means unlimited
+
+ def __init__(self, lifetime=None, namespace=None):
+ CacheStorage.__init__(self)
+ if lifetime is not None: self.lifetime = lifetime
+ self.namespace = namespace
+
+ def _load(self, cachepath):
+ key = cachepath
+ if _tenjin.logger: _tenjin.logger.info("[tenjin.gae.GaeMemcacheCacheStorage] load cache (key=%r)" % (key, ))
+ return memcache.get(key, namespace=self.namespace)
+
+ def _store(self, cachepath, dct):
+ dct.pop('bytecode', None)
+ key = cachepath
+ if _tenjin.logger: _tenjin.logger.info("[tenjin.gae.GaeMemcacheCacheStorage] store cache (key=%r)" % (key, ))
+ ret = memcache.set(key, dct, self.lifetime, namespace=self.namespace)
+ if not ret:
+ if _tenjin.logger: _tenjin.logger.info("[tenjin.gae.GaeMemcacheCacheStorage] failed to store cache (key=%r)" % (key, ))
+
+ def _delete(self, cachepath):
+ key = cachepath
+ memcache.delete(key, namespace=self.namespace)
+
+
+ class GaeMemcacheStore(KeyValueStore):
+
+ lifetime = 0
+
+ def __init__(self, lifetime=None, namespace=None):
+ if lifetime is not None: self.lifetime = lifetime
+ self.namespace = namespace
+
+ def get(self, key):
+ return memcache.get(key, namespace=self.namespace)
+
+ def set(self, key, value, lifetime=None):
+ if lifetime is None: lifetime = self.lifetime
+ if memcache.set(key, value, lifetime, namespace=self.namespace):
+ return True
+ else:
+ if _tenjin.logger: _tenjin.logger.info("[tenjin.gae.GaeMemcacheStore] failed to set (key=%r)" % (key, ))
+ return False
+
+ def delete(self, key):
+ return memcache.delete(key, namespace=self.namespace)
+
+ def has(self, key):
+ if memcache.add(key, 'dummy', namespace=self.namespace):
+ memcache.delete(key, namespace=self.namespace)
+ return False
+ else:
+ return True
+
+
+ def init():
+ global memcache, _tenjin
+ if not memcache:
+ from google.appengine.api import memcache
+ if not _tenjin: import tenjin as _tenjin
+ ## avoid cache confliction between versions
+ ver = os.environ.get('CURRENT_VERSION_ID', '1.1')#.split('.')[0]
+ Engine.cache = GaeMemcacheCacheStorage(namespace=ver)
+ ## set fragment cache store
+ helpers.fragment_cache.store = GaeMemcacheStore(namespace=ver)
+ helpers.fragment_cache.lifetime = 60 # 1 minute
+ helpers.fragment_cache.prefix = 'fragment.'
+
+
+gae = create_module('tenjin.gae', _dummy,
+ os=os, helpers=helpers, Engine=Engine,
+ CacheStorage=CacheStorage, KeyValueStore=KeyValueStore)
+
+
+del _dummy
diff --git a/cgi/tor.txt b/cgi/tor.txt
new file mode 100644
index 0000000..f748b96
--- /dev/null
+++ b/cgi/tor.txt
@@ -0,0 +1,1140 @@
+102.165.54.56
+103.194.170.223
+103.208.220.122
+103.208.220.226
+103.234.220.195
+103.234.220.197
+103.236.201.110
+103.236.201.27
+103.28.52.93
+103.28.53.138
+103.3.61.114
+103.75.190.11
+103.76.180.54
+104.131.206.23
+104.192.3.226
+104.194.228.240
+104.196.43.128
+104.200.20.46
+104.218.63.72
+104.218.63.73
+104.218.63.74
+104.218.63.75
+104.218.63.76
+104.244.73.126
+104.244.74.165
+104.244.74.78
+104.244.76.13
+104.244.77.49
+104.244.77.66
+104.40.73.53
+107.155.49.126
+107.173.58.166
+107.181.161.182
+107.181.174.66
+108.85.99.10
+109.169.33.163
+109.201.133.100
+109.236.90.209
+109.69.66.98
+109.69.67.17
+109.70.100.10
+109.70.100.2
+109.70.100.3
+109.70.100.4
+109.70.100.5
+109.70.100.6
+109.70.100.7
+109.70.100.8
+109.70.100.9
+111.69.49.124
+114.32.35.232
+115.64.95.48
+118.163.74.160
+122.147.141.130
+124.109.1.207
+125.212.241.182
+128.14.136.158
+128.31.0.13
+130.149.80.199
+130.204.161.3
+136.243.102.134
+137.74.167.96
+137.74.169.241
+138.197.177.62
+139.162.10.72
+139.162.100.194
+139.162.138.14
+139.28.36.234
+139.99.96.114
+139.99.98.191
+142.93.168.48
+143.106.60.70
+143.202.161.75
+144.217.161.119
+144.217.164.104
+144.217.165.223
+144.217.166.19
+144.217.166.26
+144.217.166.59
+144.217.166.65
+144.217.60.211
+144.217.60.239
+144.217.64.46
+144.217.7.154
+144.217.7.33
+144.217.80.80
+144.217.90.68
+145.239.82.204
+145.239.91.37
+145.239.93.33
+145.249.106.102
+145.249.107.135
+149.202.170.60
+149.202.238.204
+151.73.206.187
+153.207.207.191
+154.127.60.92
+156.54.213.67
+157.157.87.22
+158.174.122.199
+158.255.7.61
+158.69.192.200
+158.69.192.239
+158.69.193.32
+158.69.201.47
+158.69.217.87
+158.69.218.78
+158.69.37.14
+160.119.249.239
+160.119.249.24
+160.119.249.240
+160.119.253.114
+160.202.162.186
+162.213.0.243
+162.213.3.221
+162.244.80.228
+162.247.74.199
+162.247.74.200
+162.247.74.201
+162.247.74.202
+162.247.74.204
+162.247.74.206
+162.247.74.213
+162.247.74.217
+162.247.74.27
+162.247.74.7
+162.247.74.74
+163.172.12.160
+163.172.151.47
+163.172.160.182
+163.172.221.204
+163.172.41.228
+163.172.66.247
+164.132.51.91
+164.132.9.199
+164.77.133.220
+166.70.15.14
+166.70.207.2
+167.114.108.152
+167.114.34.150
+167.99.42.89
+169.197.112.26
+171.233.208.235
+171.25.193.20
+171.25.193.235
+171.25.193.25
+171.25.193.77
+171.25.193.78
+172.96.118.14
+172.98.193.43
+173.14.173.227
+173.212.244.116
+173.244.209.5
+173.255.226.142
+174.18.153.201
+176.10.104.240
+176.10.107.180
+176.10.99.200
+176.10.99.201
+176.10.99.202
+176.10.99.203
+176.10.99.204
+176.10.99.205
+176.10.99.206
+176.10.99.207
+176.10.99.208
+176.10.99.209
+176.10.99.210
+176.107.179.147
+176.121.81.51
+176.126.83.211
+176.31.208.193
+176.31.45.3
+176.53.90.26
+176.58.100.98
+176.58.89.182
+176.67.168.210
+178.165.72.177
+178.17.166.146
+178.17.166.147
+178.17.166.148
+178.17.166.149
+178.17.166.150
+178.17.170.105
+178.17.170.112
+178.17.170.13
+178.17.170.135
+178.17.170.149
+178.17.170.164
+178.17.170.194
+178.17.170.196
+178.17.170.23
+178.17.170.81
+178.17.171.102
+178.17.171.114
+178.17.171.197
+178.17.171.39
+178.17.171.78
+178.17.174.10
+178.17.174.14
+178.17.174.196
+178.17.174.198
+178.17.174.229
+178.17.174.232
+178.17.174.68
+178.175.131.194
+178.175.132.209
+178.175.132.210
+178.175.132.211
+178.175.132.212
+178.175.132.213
+178.175.132.214
+178.175.132.225
+178.175.132.226
+178.175.132.227
+178.175.132.228
+178.175.132.229
+178.175.132.230
+178.175.135.100
+178.175.135.101
+178.175.135.102
+178.175.135.99
+178.175.143.155
+178.175.143.156
+178.175.143.157
+178.175.143.158
+178.175.143.163
+178.175.143.164
+178.175.143.165
+178.175.143.166
+178.175.143.234
+178.175.143.242
+178.175.148.11
+178.175.148.165
+178.175.148.224
+178.175.148.227
+178.175.148.34
+178.175.148.45
+178.20.55.16
+178.20.55.18
+178.211.45.18
+178.239.176.73
+178.32.147.150
+178.32.181.96
+178.32.181.97
+178.32.181.98
+178.32.181.99
+178.43.184.11
+178.63.97.34
+179.176.55.216
+179.43.134.154
+179.43.134.155
+179.43.134.156
+179.43.134.157
+179.43.146.230
+179.43.148.214
+179.43.151.146
+179.48.248.17
+179.48.251.188
+18.18.248.17
+18.85.192.253
+180.150.226.99
+185.10.68.123
+185.10.68.148
+185.10.68.180
+185.10.68.217
+185.10.68.225
+185.10.68.52
+185.10.68.76
+185.100.85.101
+185.100.85.132
+185.100.85.147
+185.100.85.190
+185.100.85.61
+185.100.86.100
+185.100.86.128
+185.100.86.154
+185.100.86.182
+185.100.87.206
+185.100.87.207
+185.104.120.2
+185.104.120.3
+185.104.120.4
+185.104.120.5
+185.104.120.60
+185.104.120.7
+185.106.122.188
+185.107.47.171
+185.107.47.215
+185.107.70.202
+185.107.83.71
+185.112.146.138
+185.112.254.195
+185.121.168.254
+185.125.33.114
+185.125.33.242
+185.127.25.192
+185.127.25.68
+185.129.62.62
+185.129.62.63
+185.130.104.241
+185.14.29.189
+185.147.237.8
+185.147.80.155
+185.163.45.38
+185.165.168.168
+185.165.168.229
+185.165.168.77
+185.165.169.165
+185.165.169.62
+185.165.169.71
+185.169.42.141
+185.175.208.179
+185.175.208.180
+185.177.151.34
+185.180.221.225
+185.191.204.254
+185.193.125.42
+185.198.58.198
+185.205.210.245
+185.220.100.252
+185.220.100.253
+185.220.100.254
+185.220.100.255
+185.220.101.0
+185.220.101.1
+185.220.101.12
+185.220.101.13
+185.220.101.15
+185.220.101.20
+185.220.101.21
+185.220.101.22
+185.220.101.24
+185.220.101.25
+185.220.101.26
+185.220.101.27
+185.220.101.28
+185.220.101.29
+185.220.101.3
+185.220.101.30
+185.220.101.31
+185.220.101.32
+185.220.101.33
+185.220.101.34
+185.220.101.35
+185.220.101.44
+185.220.101.45
+185.220.101.46
+185.220.101.48
+185.220.101.49
+185.220.101.5
+185.220.101.50
+185.220.101.52
+185.220.101.53
+185.220.101.54
+185.220.101.56
+185.220.101.57
+185.220.101.58
+185.220.101.6
+185.220.101.60
+185.220.101.62
+185.220.101.65
+185.220.101.66
+185.220.101.67
+185.220.101.68
+185.220.101.69
+185.220.101.7
+185.220.101.70
+185.220.102.4
+185.220.102.6
+185.220.102.7
+185.220.102.8
+185.222.202.104
+185.222.202.12
+185.222.202.125
+185.222.202.133
+185.222.202.153
+185.222.202.221
+185.222.209.87
+185.227.68.78
+185.227.82.9
+185.233.100.23
+185.234.219.111
+185.234.219.112
+185.234.219.113
+185.234.219.114
+185.234.219.115
+185.234.219.116
+185.234.219.117
+185.234.219.118
+185.234.219.119
+185.234.219.120
+185.242.113.224
+185.244.151.149
+185.248.160.21
+185.248.160.231
+185.248.160.65
+185.255.112.137
+185.31.136.244
+185.34.33.2
+185.35.138.92
+185.4.132.135
+185.4.132.183
+185.56.171.94
+185.61.149.193
+185.65.205.10
+185.65.206.154
+185.66.200.10
+185.72.244.24
+185.86.148.109
+185.86.148.90
+185.86.149.254
+185.86.151.21
+187.178.75.109
+188.127.251.63
+188.165.59.43
+188.166.56.121
+188.166.9.235
+188.214.104.146
+188.68.45.180
+189.84.21.44
+190.10.8.50
+190.105.226.81
+190.164.230.184
+190.210.98.90
+190.216.2.136
+191.114.118.98
+192.155.95.222
+192.160.102.164
+192.160.102.165
+192.160.102.166
+192.160.102.168
+192.160.102.169
+192.160.102.170
+192.195.80.10
+192.34.80.176
+192.42.116.13
+192.42.116.14
+192.42.116.15
+192.42.116.16
+192.42.116.17
+192.42.116.18
+192.42.116.19
+192.42.116.20
+192.42.116.22
+192.42.116.23
+192.42.116.24
+192.42.116.25
+192.42.116.26
+192.42.116.27
+192.42.116.28
+193.110.157.151
+193.150.121.66
+193.169.145.194
+193.169.145.202
+193.169.145.66
+193.19.118.171
+193.201.225.45
+193.29.15.223
+193.36.119.17
+193.56.29.101
+193.9.114.139
+193.9.115.24
+193.90.12.115
+193.90.12.116
+193.90.12.117
+193.90.12.118
+193.90.12.119
+194.88.143.66
+195.123.209.67
+195.123.212.75
+195.123.213.211
+195.123.216.32
+195.123.217.153
+195.123.222.135
+195.123.224.108
+195.123.227.87
+195.123.228.161
+195.123.237.251
+195.123.245.96
+195.170.63.164
+195.176.3.19
+195.176.3.20
+195.176.3.23
+195.176.3.24
+195.189.96.147
+195.206.105.217
+195.228.45.176
+195.254.134.194
+195.254.134.242
+195.254.135.76
+196.41.123.180
+197.231.221.211
+198.167.223.111
+198.167.223.133
+198.167.223.38
+198.167.223.44
+198.233.204.165
+198.46.135.18
+198.50.191.95
+198.50.200.129
+198.50.200.131
+198.71.81.66
+198.73.51.73
+198.96.155.3
+198.98.50.112
+198.98.50.201
+198.98.52.93
+198.98.54.28
+198.98.54.34
+198.98.56.149
+198.98.57.155
+198.98.57.178
+198.98.58.135
+198.98.59.240
+198.98.62.49
+199.127.226.150
+199.195.248.177
+199.195.250.77
+199.195.252.246
+199.249.230.64
+199.249.230.65
+199.249.230.66
+199.249.230.67
+199.249.230.68
+199.249.230.69
+199.249.230.70
+199.249.230.71
+199.249.230.72
+199.249.230.73
+199.249.230.74
+199.249.230.75
+199.249.230.76
+199.249.230.77
+199.249.230.78
+199.249.230.79
+199.249.230.80
+199.249.230.81
+199.249.230.82
+199.249.230.83
+199.249.230.84
+199.249.230.85
+199.249.230.86
+199.249.230.87
+199.249.230.88
+199.249.230.89
+199.87.154.255
+200.98.137.240
+200.98.146.219
+200.98.161.148
+2001:0470:000d:06dd:0011:0000:0000:beef
+2001:0470:1f04:0d9a:0000:0000:0000:0002
+2001:0470:b304:0002:0000:0000:0051:0001
+2001:0620:20d0:0000:0000:0000:0000:0019
+2001:0620:20d0:0000:0000:0000:0000:0020
+2001:0620:20d0:0000:0000:0000:0000:0023
+2001:0620:20d0:0000:0000:0000:0000:0024
+2001:067c:2608:0000:0000:0000:0000:0001
+2001:067c:289c:0000:0000:0000:0000:0020
+2001:067c:289c:0000:0000:0000:0000:0025
+2001:067c:289c:0003:0000:0000:0000:0077
+2001:067c:289c:0003:0000:0000:0000:0078
+2001:0780:0107:000b:0000:0000:0000:0085
+2001:0981:5b21:000c:0000:0000:0000:0034
+2001:0985:7aa4:0000:0000:0000:0000:0002
+2001:0bc8:272a:0000:0000:0000:0000:0001
+2001:0bc8:3c96:0100:0000:0000:0000:0082
+2001:0bc8:4700:2000:0000:0000:0000:2317
+2001:0bc8:4700:2300:0000:0000:0004:021b
+2001:0bc8:4728:1203:0000:0000:0000:0001
+2001:0bc8:472c:7507:0000:0000:0000:0001
+2001:0bc8:472c:d10c:0000:0000:0000:0001
+2001:0bf0:0666:0000:0000:0000:0000:0666
+2001:0bf7:b201:0000:0000:0000:0000:0006
+2001:0bf7:b301:0000:0000:0000:0000:0006
+2001:1af8:4700:a012:0001:0000:0000:0001
+2001:1b60:0003:0221:3132:0102:0000:0001
+2001:1b60:0003:0221:4134:0101:0000:0001
+2001:1b60:0003:0239:1003:0103:0000:0001
+2001:1b60:0003:0239:1003:0106:0000:0001
+2001:41d0:0052:0100:0000:0000:0000:112a
+2001:41d0:0052:0500:0000:0000:0000:051a
+2001:41d0:0052:0cff:0000:0000:0000:01fb
+2001:41d0:0401:3100:0000:0000:0000:7d36
+2001:41d0:0404:0200:0000:0000:0000:1124
+2001:41d0:0601:1100:0000:0000:0000:06b0
+2001:41d0:0601:1100:0000:0000:0000:09eb
+2001:41d0:0601:1100:0000:0000:0000:0eb0
+2001:41d0:0701:1100:0000:0000:0000:0761
+2001:41d0:0701:1100:0000:0000:0000:1a12
+2001:41d0:0801:2000:0000:0000:0000:0270
+2001:41d0:1008:26d8:0000:0000:0000:0150
+2001:4b78:2006:ffc3:0000:0000:0000:0001
+2001:4ba0:fff9:0160:dead:beef:ca1f:1337
+2001:b011:4010:3264:0000:0000:0000:0006
+2002:ce3f:e590:0001:0001:0000:0000:0015
+201.80.164.203
+201.80.181.11
+204.11.50.131
+204.17.56.42
+204.194.29.4
+204.209.81.3
+204.27.60.147
+204.8.156.142
+204.85.191.30
+204.85.191.9
+205.168.84.133
+205.185.126.56
+205.185.127.219
+206.248.184.127
+206.55.74.0
+207.180.224.17
+207.192.70.250
+207.244.70.35
+209.126.101.29
+209.141.33.25
+209.141.37.237
+209.141.40.86
+209.141.41.41
+209.141.45.212
+209.141.51.150
+209.141.58.114
+209.141.61.45
+209.95.51.11
+210.140.10.24
+210.3.102.152
+212.16.104.33
+212.21.66.6
+212.47.226.52
+212.47.229.60
+212.47.248.66
+212.81.199.159
+213.108.105.71
+213.136.92.52
+213.252.140.118
+213.252.244.99
+213.61.215.53
+213.95.149.22
+216.158.98.38
+216.19.178.143
+216.218.134.12
+216.239.90.19
+216.244.85.211
+217.115.10.131
+217.115.10.132
+217.12.221.196
+217.12.223.56
+217.170.197.83
+217.170.197.89
+217.182.78.177
+220.135.203.167
+223.26.48.248
+23.129.64.101
+23.129.64.104
+23.129.64.105
+23.129.64.106
+23.239.23.104
+23.94.113.11
+24.20.43.120
+24.3.111.78
+2400:8902:0000:0000:f03c:91ff:fe6b:3903
+2600:3c00:0000:0000:f03c:91ff:fee2:4963
+2600:3c01:0000:0000:f03c:91ff:fe30:ec17
+2600:3c03:0000:0000:f03c:91ff:fefa:755c
+2601:01c2:1900:f202:0c2b:7370:df29:2ffe
+2604:9a00:2010:a08d:0010:0000:0000:0023
+2605:2700:0000:0002:a800:00ff:fe20:0db3
+2605:2700:0000:0002:a800:00ff:fe39:0574
+2605:2700:0000:0002:a800:00ff:fe64:64ea
+2605:4d00:0000:0002:0000:0000:0000:006e
+2605:6400:0010:020b:226d:70ab:4c95:029b
+2605:6400:0010:0549:0000:0000:0000:0001
+2605:6400:0010:0655:a871:c796:0015:f519
+2605:6400:0020:0693:279d:170f:8868:bc3e
+2605:6400:0020:09ce:0000:0000:0000:0001
+2605:6400:0020:0e9d:2309:1a4d:8bd7:ea1c
+2605:6400:0030:fa4e:aa41:e6cb:ec4d:230a
+2605:6400:0030:fa6b:0000:0000:0000:0001
+2605:e200:d111:0001:0225:90ff:fe24:3f9e
+2605:f700:00c0:0001:0000:0000:0de9:142a
+2607:5300:0120:0e93:0000:0000:0000:0110
+2607:5300:0201:3100:0000:0000:0000:0c20
+2607:ff68:0100:0089:0000:0000:0000:0005
+2620:0007:6001:0000:0000:ffff:c759:e640
+2620:0007:6001:0000:0000:ffff:c759:e641
+2620:0007:6001:0000:0000:ffff:c759:e642
+2620:0007:6001:0000:0000:ffff:c759:e643
+2620:0007:6001:0000:0000:ffff:c759:e644
+2620:0007:6001:0000:0000:ffff:c759:e645
+2620:0007:6001:0000:0000:ffff:c759:e646
+2620:0007:6001:0000:0000:ffff:c759:e647
+2620:0007:6001:0000:0000:ffff:c759:e648
+2620:0007:6001:0000:0000:ffff:c759:e649
+2620:0007:6001:0000:0000:ffff:c759:e64a
+2620:0007:6001:0000:0000:ffff:c759:e64b
+2620:0007:6001:0000:0000:ffff:c759:e64c
+2620:0007:6001:0000:0000:ffff:c759:e64d
+2620:0007:6001:0000:0000:ffff:c759:e64e
+2620:0007:6001:0000:0000:ffff:c759:e64f
+2620:0007:6001:0000:0000:ffff:c759:e650
+2620:0007:6001:0000:0000:ffff:c759:e651
+2620:0007:6001:0000:0000:ffff:c759:e652
+2620:0007:6001:0000:0000:ffff:c759:e653
+2620:0007:6001:0000:0000:ffff:c759:e654
+2620:0007:6001:0000:0000:ffff:c759:e655
+2620:0007:6001:0000:0000:ffff:c759:e656
+2620:0007:6001:0000:0000:ffff:c759:e657
+2620:0007:6001:0000:0000:ffff:c759:e658
+2620:0007:6001:0000:0000:ffff:c759:e659
+2620:0132:300c:c01d:0000:0000:0000:0004
+2620:0132:300c:c01d:0000:0000:0000:0005
+2620:0132:300c:c01d:0000:0000:0000:0006
+2620:0132:300c:c01d:0000:0000:0000:0008
+2620:0132:300c:c01d:0000:0000:0000:0009
+2620:0132:300c:c01d:0000:0000:0000:000a
+2620:018c:0000:1001:0000:0000:0000:0101
+2620:018c:0000:1001:0000:0000:0000:0104
+2620:018c:0000:1001:0000:0000:0000:0105
+2620:018c:0000:1001:0000:0000:0000:0106
+27.102.128.26
+2a00:0c98:2030:a03e:0002:0000:0000:0a10
+2a00:1298:8011:0212:0000:0000:0000:0163
+2a00:1298:8011:0212:0000:0000:0000:0164
+2a00:1298:8011:0212:0000:0000:0000:0165
+2a00:1328:e102:8000:0000:0000:0000:0131
+2a00:1768:1001:0021:0000:0000:32a3:201a
+2a00:1768:2001:0023:1000:0000:0000:0200
+2a00:1768:6001:0016:0000:0000:0000:0071
+2a00:1dc0:2048:0000:0000:0000:0000:0002
+2a00:1dc0:cafe:0000:0000:0000:d6a2:ae67
+2a00:1dc0:cafe:0000:0000:0000:f290:7489
+2a00:1dc0:caff:0029:0000:0000:0000:6d8e
+2a00:1dc0:caff:003a:0000:0000:0000:dcbe
+2a00:1dc0:caff:0054:0000:0000:0000:a46d
+2a00:1dc0:caff:0071:0000:0000:0000:e4da
+2a00:1dc0:caff:0072:0000:0000:0000:2cb4
+2a00:1dc0:caff:007d:0000:0000:0000:8254
+2a00:1dc0:caff:008b:0000:0000:0000:5b9a
+2a00:1dc0:caff:009e:0000:0000:0000:8e67
+2a00:1dc0:caff:00b0:0000:0000:0000:93c4
+2a00:1dc0:caff:00f6:0000:0000:0000:28ad
+2a00:1dc0:caff:00f8:0000:0000:0001:c46a
+2a00:1dc0:caff:010d:0000:0000:0000:234b
+2a00:1dc0:caff:0111:0000:0000:0000:785b
+2a00:1dc0:caff:0127:0000:0000:0000:e359
+2a00:1dc0:caff:0129:0000:0000:0000:4938
+2a00:1dc0:caff:0138:0000:0000:0000:94d2
+2a00:1dc0:caff:014e:0000:0000:0000:9ecd
+2a00:1dc0:caff:0153:0000:0000:0000:d2c7
+2a00:1dc0:caff:0159:0000:0000:0000:4d79
+2a00:1dc0:caff:015c:0000:0000:0000:5627
+2a00:1dc0:caff:0168:0000:0000:0000:6b79
+2a00:5880:1801:0000:2891:33ff:fe93:d6a0
+2a01:04f9:c010:08fb:0000:0000:0000:0bee
+2a01:0e35:8be7:65f0:0043:07ff:fe82:ac61
+2a01:7e00:0000:0000:f03c:91ff:fe56:2656
+2a01:7e01:0000:0000:f03c:91ff:fe6b:575b
+2a02:0418:6017:0000:0000:0000:0000:0147
+2a02:0418:6017:0000:0000:0000:0000:0148
+2a02:0a00:2000:0034:0000:0000:0000:0195
+2a02:0ec0:0209:0010:0000:0000:0000:0004
+2a02:2970:1002:0000:5054:11ff:fe21:fb21
+2a02:2970:1002:0000:5054:45ff:fe4b:5a29
+2a02:2970:1002:0000:5054:a2ff:fed6:4d6c
+2a02:2970:1002:0000:5054:a8ff:fe63:b164
+2a02:29e0:0002:0006:0001:0001:1156:b142
+2a02:29e0:0002:0006:0001:0001:1628:58bb
+2a02:7aa0:0043:0000:0000:0000:1d04:1c97
+2a03:4000:0002:0a11:3a58:da1f:cffa:01bc
+2a03:4000:0021:047a:0de1:0ea7:dead:beef
+2a03:4000:0032:0488:08a9:72ff:fef6:07aa
+2a03:b0c0:0000:1010:0000:0000:024c:1001
+2a03:b0c0:0002:00d0:0000:0000:0db1:4001
+2a03:b0c0:0003:00d0:0000:0000:0d9a:3001
+2a03:e600:0100:0000:0000:0000:0000:0002
+2a03:e600:0100:0000:0000:0000:0000:0003
+2a03:e600:0100:0000:0000:0000:0000:0004
+2a03:e600:0100:0000:0000:0000:0000:0005
+2a03:e600:0100:0000:0000:0000:0000:0006
+2a03:e600:0100:0000:0000:0000:0000:0007
+2a03:e600:0100:0000:0000:0000:0000:0008
+2a03:e600:0100:0000:0000:0000:0000:0009
+2a03:e600:0100:0000:0000:0000:0000:000a
+2a04:9dc0:00c1:0007:0216:3eff:fe5c:3d83
+2a06:1700:0000:000b:0000:0000:44cb:00d9
+2a06:1700:0000:001f:0000:0000:0000:0031
+2a06:1700:0001:0000:0000:0000:0000:0007
+2a06:1700:0001:0000:0000:0000:0000:0011
+2a06:3000:0000:0000:0000:0000:0120:0002
+2a06:3000:0000:0000:0000:0000:0120:0003
+2a06:3000:0000:0000:0000:0000:0120:0004
+2a06:3000:0000:0000:0000:0000:0120:0005
+2a06:3000:0000:0000:0000:0000:0120:0007
+2a06:3000:0000:0000:0000:0000:0120:0060
+2a06:d380:0000:3700:0000:0000:0000:0062
+2a06:d380:0000:3700:0000:0000:0000:0063
+2a0b:f4c0:016c:0001:0000:0000:0000:0001
+2a0b:f4c0:016c:0002:0000:0000:0000:0001
+2a0b:f4c0:016c:0003:0000:0000:0000:0001
+2a0b:f4c0:016c:0004:0000:0000:0000:0001
+2a0b:f4c1:0000:0000:0000:0000:0000:0004
+2a0b:f4c1:0000:0000:0000:0000:0000:0006
+2a0b:f4c1:0000:0000:0000:0000:0000:0007
+2a0b:f4c1:0000:0000:0000:0000:0000:0008
+2a0c:b807:8000:c93a:ff51:90ac:0000:13fc
+2a0c:b807:8000:c93a:ff51:90ac:0000:1b88
+2a0c:b807:8000:c93a:ff51:90ac:0000:1bae
+2c0f:f930:0000:0003:0000:0000:0000:0221
+2c0f:f930:0000:0005:0000:0000:0000:0038
+31.131.2.19
+31.131.4.171
+31.148.220.211
+31.185.104.19
+31.185.104.20
+31.185.104.21
+31.185.27.203
+31.220.0.225
+31.220.40.54
+31.220.42.86
+31.31.72.24
+31.31.74.131
+31.31.74.47
+35.0.127.52
+37.128.222.30
+37.134.164.64
+37.139.8.104
+37.187.105.104
+37.187.180.18
+37.187.239.8
+37.200.98.117
+37.220.36.240
+37.228.129.2
+37.235.48.36
+37.28.154.68
+37.48.120.196
+37.9.231.195
+38.117.96.154
+40.124.44.53
+41.215.241.146
+45.125.65.45
+45.33.43.215
+45.35.72.85
+45.56.103.80
+45.62.250.175
+45.62.250.179
+45.64.186.102
+45.66.32.220
+45.76.115.159
+45.79.144.222
+45.79.73.22
+46.101.61.36
+46.105.52.65
+46.165.230.5
+46.165.245.154
+46.165.254.166
+46.166.139.35
+46.167.245.51
+46.17.46.199
+46.173.214.3
+46.182.106.190
+46.182.18.29
+46.182.18.40
+46.182.19.15
+46.182.19.219
+46.246.49.139
+46.250.220.166
+46.29.248.238
+46.36.36.184
+46.38.235.14
+46.4.144.81
+46.98.199.52
+46.98.200.43
+47.89.178.105
+49.50.107.221
+49.50.66.209
+5.135.158.101
+5.135.65.145
+5.150.254.67
+5.189.143.169
+5.189.146.133
+5.196.1.129
+5.196.66.162
+5.199.130.188
+5.2.64.194
+5.2.77.146
+5.200.52.112
+5.252.176.20
+5.254.146.7
+5.3.163.124
+5.34.181.34
+5.34.181.35
+5.34.183.105
+5.39.217.14
+5.45.76.56
+5.61.37.133
+5.79.68.161
+5.79.86.15
+5.79.86.16
+50.247.195.124
+50.7.151.127
+50.7.176.2
+51.15.0.226
+51.15.106.67
+51.15.117.50
+51.15.123.230
+51.15.125.181
+51.15.128.3
+51.15.187.209
+51.15.209.128
+51.15.224.0
+51.15.233.253
+51.15.235.211
+51.15.252.1
+51.15.3.40
+51.15.34.214
+51.15.36.100
+51.15.37.97
+51.15.43.205
+51.15.48.204
+51.15.49.134
+51.15.53.83
+51.15.56.18
+51.15.59.175
+51.15.59.9
+51.15.68.66
+51.15.75.133
+51.15.80.14
+51.15.92.212
+51.159.1.114
+51.254.208.245
+51.254.48.93
+51.255.106.85
+51.38.113.64
+51.38.134.189
+51.38.162.232
+51.38.64.136
+51.68.174.112
+51.68.214.45
+51.75.253.147
+51.75.71.123
+51.77.177.194
+51.77.193.218
+51.77.201.37
+51.77.62.52
+52.15.194.28
+52.167.231.173
+54.36.189.105
+54.36.222.37
+54.37.16.241
+54.37.234.66
+54.39.148.232
+54.39.148.233
+54.39.148.234
+54.39.151.167
+58.153.198.85
+59.115.159.251
+59.127.163.155
+62.102.148.67
+62.102.148.68
+62.102.148.69
+62.210.105.86
+62.210.116.201
+62.210.37.82
+64.113.32.29
+64.137.162.34
+64.27.17.140
+65.181.122.48
+65.181.123.254
+65.181.124.115
+65.19.167.130
+65.19.167.131
+65.19.167.132
+66.110.216.10
+66.146.193.33
+66.155.4.213
+66.175.208.248
+66.222.153.25
+66.42.224.235
+67.163.131.76
+67.215.255.140
+68.46.79.221
+69.162.107.5
+69.164.207.234
+70.168.93.214
+71.19.144.106
+71.19.144.148
+71.19.148.20
+72.14.179.10
+72.210.252.137
+74.82.47.194
+77.247.181.162
+77.247.181.163
+77.247.181.164
+77.247.181.165
+77.247.181.166
+77.250.227.202
+77.55.212.215
+77.68.42.132
+77.73.69.90
+77.81.104.124
+77.81.247.72
+78.109.23.2
+78.130.128.106
+78.142.175.70
+78.142.19.43
+78.21.17.242
+78.92.23.245
+79.134.234.247
+79.134.235.243
+79.134.235.253
+79.143.186.17
+79.172.193.32
+79.232.118.2
+80.127.116.96
+80.169.241.76
+80.241.60.207
+80.67.172.162
+80.68.92.225
+80.79.23.7
+81.169.136.206
+81.17.27.134
+81.17.27.135
+81.17.27.136
+81.17.27.137
+81.171.29.146
+81.49.51.12
+82.118.242.113
+82.118.242.128
+82.161.210.87
+82.221.128.191
+82.221.131.102
+82.221.131.5
+82.221.131.71
+82.221.139.190
+82.221.141.96
+82.223.14.245
+82.223.27.82
+82.228.252.20
+82.66.140.131
+82.94.132.34
+82.94.251.227
+83.136.106.136
+83.136.106.153
+84.19.182.33
+84.200.12.61
+84.200.50.18
+84.209.51.186
+84.53.192.243
+84.53.225.118
+85.119.82.142
+85.159.237.210
+85.214.243.115
+85.235.65.198
+85.248.227.163
+85.248.227.164
+85.248.227.165
+85.25.44.141
+86.104.15.15
+87.118.110.27
+87.118.112.63
+87.118.116.103
+87.118.116.12
+87.118.116.90
+87.118.122.30
+87.118.122.51
+87.118.92.43
+87.120.254.204
+87.120.254.223
+87.120.36.157
+87.122.229.240
+87.222.199.132
+87.64.102.248
+88.190.118.95
+88.77.181.199
+88.99.35.242
+89.14.189.217
+89.144.12.17
+89.187.143.31
+89.187.143.81
+89.203.249.251
+89.234.157.254
+89.234.190.157
+89.236.112.100
+89.31.57.58
+91.121.192.154
+91.121.251.65
+91.146.121.3
+91.153.76.138
+91.203.145.116
+91.203.146.126
+91.203.5.146
+91.203.5.165
+91.207.174.75
+91.219.236.171
+91.219.237.244
+91.219.238.95
+91.219.28.60
+91.221.57.179
+91.234.99.83
+91.250.241.241
+91.92.109.43
+91.92.109.53
+92.222.115.28
+92.222.180.10
+92.222.22.113
+92.222.38.67
+92.63.173.28
+93.115.241.194
+93.174.93.133
+93.174.93.6
+94.100.6.27
+94.100.6.72
+94.102.49.152
+94.102.51.78
+94.156.77.134
+94.230.208.147
+94.230.208.148
+94.242.57.161
+94.242.59.89
+94.32.66.15
+95.103.57.132
+95.128.43.164
+95.130.10.69
+95.130.11.170
+95.130.12.33
+95.130.9.90
+95.141.35.15
+95.142.161.63
+95.143.193.125
+95.165.133.22
+95.179.150.158
+95.211.118.194
+95.216.107.148
+95.216.145.1
+95.216.2.172
+95.42.126.41
+96.66.15.147
+96.70.31.155
+97.74.237.196
+98.174.90.43 \ No newline at end of file
diff --git a/cgi/weabot.py b/cgi/weabot.py
new file mode 100755
index 0000000..2e11252
--- /dev/null
+++ b/cgi/weabot.py
@@ -0,0 +1,1021 @@
+#!/usr/bin/python
+# coding=utf-8
+
+# Remove the first line to use the env command to locate python
+
+import os
+import time
+import datetime
+import random
+import cgi
+import _mysql
+from Cookie import SimpleCookie
+
+import tenjin
+import manage
+import oekaki
+import gettext
+from database import *
+from settings import Settings
+from framework import *
+from formatting import *
+from post import *
+from img import *
+
+__version__ = "0.8.7"
+
+# Set to True to disable weabot's exception routing and enable profiling
+_DEBUG = False
+
+# Set to True to save performance data to weabot.txt
+_LOG = False
+
+class weabot(object):
+ def __init__(self, environ, start_response):
+ global _DEBUG
+ self.environ = environ
+ if self.environ["PATH_INFO"].startswith("/weabot.py/"):
+ self.environ["PATH_INFO"] = self.environ["PATH_INFO"][11:]
+
+ self.start = start_response
+ self.formdata = getFormData(self)
+
+ self.output = ""
+
+ self.handleRequest()
+
+ # Localization Code
+ lang = gettext.translation('weabot', './locale', languages=[Settings.LANG])
+ lang.install()
+
+ logTime("**Start**")
+ if _DEBUG:
+ import cProfile
+
+ prof = cProfile.Profile()
+ prof.runcall(self.run)
+ prof.dump_stats('stats.prof')
+ else:
+ try:
+ self.run()
+ except UserError, message:
+ self.error(message)
+ except Exception, inst:
+ import sys, traceback
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ detail = ((os.path.basename(o[0]),o[1],o[2],o[3]) for o in traceback.extract_tb(exc_traceback))
+ self.exception(type(inst), inst, detail)
+
+ # close database and finish
+ CloseDb()
+ logTime("**End**")
+
+ if _LOG:
+ logfile = open(Settings.ROOT_DIR + "weabot.txt", "w")
+ logfile.write(logTimes())
+ logfile.close()
+
+ def __iter__(self):
+ self.handleResponse()
+ self.start("200 OK", self.headers)
+ yield self.output
+
+ def error(self, message):
+ board = Settings._.BOARD
+ if board:
+ if board['board_type'] == '1':
+ info = {}
+ info['host'] = self.environ["REMOTE_ADDR"]
+ info['name'] = self.formdata.get('fielda', '')
+ info['email'] = self.formdata.get('fieldb', '')
+ info['message'] = self.formdata.get('message', '')
+
+ self.output += renderTemplate("txt_error.html", {"info": info, "error": message})
+ else:
+ mobile = self.formdata.get('mobile', '')
+ if mobile:
+ self.output += renderTemplate("mobile/error.html", {"error": message})
+ else:
+ self.output += renderTemplate("error.html", {"error": message, "boards_url": Settings.BOARDS_URL, "board": board["dir"]})
+ else:
+ self.output += renderTemplate("exception.html", {"exception": None, "error": message})
+
+ def exception(self, type, message, detail):
+ self.output += renderTemplate("exception.html", {"exception": type, "error": message, "detail": detail})
+
+ def handleRequest(self):
+ self.headers = [("Content-Type", "text/html")]
+ self.handleCookies()
+
+ def handleResponse(self):
+ if self._cookies is not None:
+ for cookie in self._cookies.values():
+ self.headers.append(("Set-Cookie", cookie.output(header="")))
+
+ def handleCookies(self):
+ self._cookies = SimpleCookie()
+ self._cookies.load(self.environ.get("HTTP_COOKIE", ""))
+
+ def run(self):
+ path_split = self.environ["PATH_INFO"].split("/")
+ caught = False
+
+ if Settings.FULL_MAINTENANCE:
+ raise UserError, _("%s is currently under maintenance. We'll be back.") % Settings.SITE_TITLE
+
+ if len(path_split) > 1:
+ if path_split[1] == "post":
+ # Making a post
+ caught = True
+
+ if 'password' not in self.formdata:
+ raise UserError, "El request está incompleto."
+
+ # let's get all the POST data we need
+ ip = self.environ["REMOTE_ADDR"]
+ boarddir = self.formdata.get('board')
+ parent = self.formdata.get('parent')
+ trap1 = self.formdata.get('name', '')
+ trap2 = self.formdata.get('email', '')
+ name = self.formdata.get('fielda', '')
+ email = self.formdata.get('fieldb', '')
+ subject = self.formdata.get('subject', '')
+ message = self.formdata.get('message', '')
+ file = self.formdata.get('file')
+ file_original = self.formdata.get('file_original')
+ spoil = self.formdata.get('spoil')
+ oek_file = self.formdata.get('oek_file')
+ password = self.formdata.get('password', '')
+ noimage = self.formdata.get('noimage')
+ mobile = ("mobile" in self.formdata.keys())
+
+ # call post function
+ (post_url, ttaken) = self.make_post(ip, boarddir, parent, trap1, trap2, name, email, subject, message, file, file_original, spoil, oek_file, password, noimage, mobile)
+
+ # make redirect
+ self.output += make_redirect(post_url, ttaken)
+ elif path_split[1] == "environ":
+ caught = True
+
+ self.output += repr(self.environ)
+ elif path_split[1] == "delete":
+ # Deleting a post
+ caught = True
+
+ boarddir = self.formdata.get('board')
+ postid = self.formdata.get('delete')
+ imageonly = self.formdata.get('imageonly')
+ password = self.formdata.get('password')
+ mobile = self.formdata.get('mobile')
+
+ # call delete function
+ self.delete_post(boarddir, postid, imageonly, password, mobile)
+ elif path_split[1] == "anarkia":
+ import anarkia
+ caught = True
+ OpenDb()
+ anarkia.anarkia(self, path_split)
+ elif path_split[1] == "manage":
+ caught = True
+ OpenDb()
+ manage.manage(self, path_split)
+ elif path_split[1] == "api":
+ import api
+ caught = True
+ self.headers = [("Content-Type", "application/json")]
+ OpenDb()
+ api.api(self, path_split)
+ elif path_split[1] == "threadlist":
+ OpenDb()
+ board = setBoard(path_split[2])
+ caught = True
+ if board['board_type'] != '1':
+ raise UserError, "No disponible para esta sección."
+ self.output = threadList(0)
+ elif path_split[1] == "mobile":
+ OpenDb()
+ board = setBoard(path_split[2])
+ caught = True
+ self.output = threadList(1)
+ elif path_split[1] == "mobilelist":
+ OpenDb()
+ board = setBoard(path_split[2])
+ caught = True
+ self.output = threadList(2)
+ elif path_split[1] == "mobilecat":
+ OpenDb()
+ board = setBoard(path_split[2])
+ caught = True
+ self.output = threadList(3)
+ elif path_split[1] == "mobilenew":
+ OpenDb()
+ board = setBoard(path_split[2])
+ caught = True
+ self.output = renderTemplate('txt_newthread.html', {}, True)
+ elif path_split[1] == "mobilehome":
+ OpenDb()
+ latest_age = getLastAge(Settings.HOME_LASTPOSTS)
+ for threads in latest_age:
+ content = threads['url']
+ content = content.replace('/read/', '/')
+ content = content.replace('/res/', '/')
+ content = content.replace('.html', '')
+ threads['url'] = content
+ caught = True
+ self.output = renderTemplate('latest.html', {'latest_age': latest_age}, True)
+ elif path_split[1] == "mobilenewest":
+ OpenDb()
+ newthreads = getNewThreads(Settings.HOME_LASTPOSTS)
+ for threads in newthreads:
+ content = threads['url']
+ content = content.replace('/read/', '/')
+ content = content.replace('/res/', '/')
+ content = content.replace('.html', '')
+ threads['url'] = content
+ caught = True
+ self.output = renderTemplate('newest.html', {'newthreads': newthreads}, True)
+ elif path_split[1] == "mobileread":
+ OpenDb()
+ board = setBoard(path_split[2])
+ caught = True
+ if len(path_split) > 4 and path_split[4] and board['board_type'] == '1':
+ #try:
+ self.output = dynamicRead(int(path_split[3]), path_split[4], True)
+ #except:
+ # self.output = threadPage(path_split[3], True)
+ elif board['board_type'] == '1':
+ self.output = threadPage(0, True, path_split[3])
+ else:
+ self.output = threadPage(path_split[3], True)
+ elif path_split[1] == "catalog":
+ OpenDb()
+ board = setBoard(path_split[2])
+ caught = True
+ sort = self.formdata.get('sort', '')
+ self.output = catalog(sort)
+ elif path_split[1] == "oekaki":
+ caught = True
+ OpenDb()
+ oekaki.oekaki(self, path_split)
+ elif path_split[1] == "play":
+ # Module player
+ caught = True
+ boarddir = path_split[2]
+ modfile = path_split[3]
+ self.output = renderTemplate('mod.html', {'board': boarddir, 'modfile': modfile})
+ elif path_split[1] == "report":
+ # Report post, check if they are enabled
+ # Can't report if banned
+ caught = True
+ ip = self.environ["REMOTE_ADDR"]
+ boarddir = path_split[2]
+ postid = int(path_split[3])
+ reason = self.formdata.get('reason')
+ try:
+ txt = True
+ postshow = int(path_split[4])
+ except:
+ txt = False
+ postshow = postid
+
+ self.report(ip, boarddir, postid, reason, txt, postshow)
+ elif path_split[1] == "stats":
+ caught = True
+ self.stats()
+ elif path_split[1] == "random":
+ caught = True
+ OpenDb()
+ board = FetchOne("SELECT `id`, `dir`, `board_type` FROM `boards` WHERE `secret` = 0 AND `id` <> 1 AND `id` <> 13 AND `id` <> 34 ORDER BY RAND() LIMIT 1")
+ thread = FetchOne("SELECT `id`, `timestamp` FROM `posts` WHERE `parentid` = 0 AND `boardid` = %s ORDER BY RAND() LIMIT 1" % board['id'])
+ if board['board_type'] == '1':
+ url = Settings.HOME_URL + board['dir'] + '/read/' + thread['timestamp'] + '/'
+ else:
+ url = Settings.HOME_URL + board['dir'] + '/res/' + thread['id'] + '.html'
+ self.output += '<html xmlns="http://www.w3.org/1999/xhtml"><meta http-equiv="refresh" content="0;url=%s" /><body><p>...</p></body></html>' % url
+ elif path_split[1] == "nostalgia":
+ caught = True
+ OpenDb()
+ thread = FetchOne("SELECT `timestamp` FROM `archive` WHERE `boardid` = 9 AND `timestamp` < 1462937230 ORDER BY RAND() LIMIT 1")
+ url = Settings.HOME_URL + '/zonavip/read/' + thread['timestamp'] + '/'
+ self.output += '<html xmlns="http://www.w3.org/1999/xhtml"><meta http-equiv="refresh" content="0;url=%s" /><body><p>...</p></body></html>' % url
+ elif path_split[1] == "banned":
+ OpenDb()
+ packed_ip = inet_aton(self.environ["REMOTE_ADDR"])
+ bans = FetchAll("SELECT * FROM `bans` WHERE (`netmask` IS NULL AND `ip` = '"+str(packed_ip)+"') OR (`netmask` IS NOT NULL AND '"+str(packed_ip)+"' & `netmask` = `ip`)")
+ if bans:
+ for ban in bans:
+ if ban["boards"] != "":
+ boards = pickle.loads(ban["boards"])
+ if ban["boards"] == "" or path_split[2] in boards:
+ caught = True
+ if ban["boards"]:
+ boards_str = '/' + '/, /'.join(boards) + '/'
+ else:
+ boards_str = _("all boards")
+ if ban["until"] != "0":
+ expire = formatTimestamp(ban["until"])
+ else:
+ expire = ""
+
+ template_values = {
+ 'cgi_url': Settings.CGI_URL,
+ 'return_board': path_split[2],
+ 'boards_str': boards_str,
+ 'reason': ban['reason'],
+ 'added': formatTimestamp(ban["added"]),
+ 'expire': expire,
+ 'ip': self.environ["REMOTE_ADDR"],
+ }
+ self.output = renderTemplate('banned.html', template_values)
+ else:
+ if len(path_split) > 2:
+ caught = True
+ self.output += '<html xmlns="http://www.w3.org/1999/xhtml"><body><meta http-equiv="refresh" content="0;url=%s" /><p>%s</p></body></html>' % (Settings.HOME_URL + path_split[2], _("Your ban has expired. Redirecting..."))
+ elif path_split[1] == "read":
+ # Textboard read:
+ if len(path_split) > 4:
+ caught = True
+ # 2: board
+ # 3: thread
+ # 4: post(s)
+ OpenDb()
+ board = setBoard(path_split[2])
+ self.output = dynamicRead(int(path_split[3]), path_split[4])
+ elif path_split[1] == "preview":
+ caught = True
+ OpenDb()
+ try:
+ board = setBoard(self.formdata["board"])
+ message = format_post(self.formdata["message"], self.environ["REMOTE_ADDR"], self.formdata["parentid"])
+ self.output = message
+ except Exception, messagez:
+ self.output = "Error: " + str(messagez) + " : " + str(self.formdata)
+ if not caught:
+ # Redirect the user back to the front page
+ self.output += '<html xmlns="http://www.w3.org/1999/xhtml"><body><meta http-equiv="refresh" content="0;url=%s" /><p>--&gt; --&gt; --&gt;</p></body></html>' % Settings.HOME_URL
+
+ def make_post(self, ip, boarddir, parent, trap1, trap2, name, email, subject, message, file, file_original, spoil, oek_file, password, noimage, mobile):
+ _STARTTIME = time.clock() # Comment if not debug
+
+ # open database
+ OpenDb()
+
+ # set the board
+ board = setBoard(boarddir)
+
+ if board["dir"] != ["anarkia"]:
+ if addressIsProxy(ip):
+ raise UserError, "Proxy prohibido en esta sección."
+
+ # check length of fields
+ if len(name) > 50:
+ raise UserError, "El campo de nombre es muy largo."
+ if len(email) > 50:
+ raise UserError, "El campo de e-mail es muy largo."
+ if len(subject) > 100:
+ raise UserError, "El campo de asunto es muy largo."
+ if len(message) > 8000:
+ raise UserError, "El campo de mensaje es muy largo."
+ if message.count('\n') > 50:
+ raise UserError, "El mensaje tiene muchos saltos de línea."
+
+ # anti-spam trap
+ if trap1 or trap2:
+ raise UserError, "Te quedan tres días de vida."
+
+ # Create a single datetime now so everything syncs up
+ t = time.time()
+
+ # Delete expired bans
+ deletedBans = UpdateDb("DELETE FROM `bans` WHERE `until` != 0 AND `until` < " + str(timestamp()))
+ if deletedBans > 0:
+ regenerateAccess()
+
+ # Redirect to ban page if user is banned
+ if addressIsBanned(ip, board["dir"]):
+ #raise UserError, 'Tu host está en la lista negra.'
+ raise UserError, '<meta http-equiv="refresh" content="0; url=/cgi/banned/%s">' % board["dir"]
+
+ # Disallow posting if the site OR board is in maintenance
+ if Settings.MAINTENANCE:
+ raise UserError, _("%s is currently under maintenance. We'll be back.") % Settings.SITE_TITLE
+ if board["locked"] == '1':
+ raise UserError, _("This board is closed. You can't post in it.")
+
+ # create post object
+ post = Post(board["id"])
+ post["ip"] = inet_aton(ip)
+ post["timestamp"] = post["bumped"] = int(t)
+ post["timestamp_formatted"] = formatTimestamp(t)
+
+ # load parent info if we are replying
+ parent_post = None
+ parent_timestamp = post["timestamp"]
+ if parent:
+ parent_post = get_parent_post(parent, board["id"])
+ parent_timestamp = parent_post['timestamp']
+ post["parentid"] = parent_post['id']
+ post["bumped"] = parent_post['bumped']
+ if parent_post['locked'] == '1':
+ raise UserError, _("The thread is closed. You can't post in it.")
+
+ # check if the user is flooding
+ flood_check(t, post, board["id"])
+
+ # use fields only if enabled
+ if board["disable_name"] != '1':
+ post["name"] = cleanString(name)
+ post["email"] = cleanString(email, quote=True)
+ if board["disable_subject"] != '1':
+ post["subject"] = cleanString(subject)
+
+ # process tripcodes
+ post["name"], post["tripcode"] = tripcode(post["name"])
+
+ # Remove carriage return, they're useless
+ message = message.replace("\r", "")
+
+ # check ! functions before
+ extend = extend_str = dice = ball = None
+
+ if not post["parentid"] and board["dir"] not in ['bai', 'world']:
+ # creating thread
+ __extend = re.compile(r"^!extend(:\w+)(:\w+)?\n")
+ res = __extend.match(message)
+ if res:
+ extend = res.groups()
+ # truncate extend
+ extend_str = res.group(0)
+ message = message[res.end(0):]
+
+ if board["dir"] in ['juegos', '0', 'polka']:
+ __dice = re.compile(r"^!dado(:\w+)(:\w+)?\n")
+ res = __dice.match(message)
+ if res:
+ dice = res.groups()
+ message = message[res.end(0):]
+
+ if board["dir"] in ['zonavip', '0', 'polka']:
+ __ball = re.compile(r"^!bola8\n")
+ res = __ball.match(message)
+ if res:
+ ball = True
+ message = message[res.end(0):]
+
+ # use and format message
+ if message.strip():
+ post["message"] = format_post(message, ip, post["parentid"], parent_timestamp)
+
+ # add function messages
+ if extend_str:
+ extend_str = extend_str.replace('!extend', 'EXTEND')
+ post["message"] += '<hr />' + extend_str + ' configurado.'
+ if dice:
+ post["message"] += '<hr />' + throw_dice(dice)
+ if ball:
+ post["message"] += '<hr />' + magic_ball()
+
+ # remove sage from wrong fields
+ if post["name"].lower() == 'sage':
+ post["name"] = random.choice(board["anonymous"].split('|'))
+ if post["subject"].lower() == 'sage':
+ post["subject"] = board["subject"]
+
+ if not post["parentid"] and post["email"].lower() == 'sage':
+ post["email"] = ""
+
+ # disallow illegal characters
+ if post["name"]:
+ post["name"] = post["name"].replace('★', '☆')
+ post["name"] = post["name"].replace('◆', '◇')
+
+ # process capcodes
+ cap_id = hide_end = None
+ if post["name"] in Settings.CAPCODES:
+ capcode = Settings.CAPCODES[post["name"]]
+ if post["tripcode"] == (Settings.TRIP_CHAR + capcode[0]):
+ post["name"], post["tripcode"] = capcode[1], capcode[2]
+ #if board['board_type'] == '1':
+ # post["name"], post["tripcode"] = capcode[1], ''
+ #else:
+ # post["name"] = post["tripcode"] = ''
+ # post["message"] = ('[<span style="color:red">%s</span>]<br />' % capcode[2]) + post["message"]
+
+ cap_id, hide_end = capcode[3], capcode[4]
+
+ # hide ip if necessary
+ if hide_end:
+ post["ip"] = 0
+
+ # use password
+ post["password"] = password
+
+ # EXTEND feature
+ if post["parentid"] and board["dir"] not in ['bai', 'world']:
+ # replying
+ __extend = re.compile(r"<hr />EXTEND(:\w+)(:\w+)?\b")
+ res = __extend.search(parent_post["message"])
+ if res:
+ extend = res.groups()
+
+ # compatibility : old id function
+ if 'id' in parent_post["email"]:
+ board["useid"] = '3'
+
+ if 'id' in post["email"]:
+ board["useid"] = '3'
+
+ if extend:
+ try:
+ # 1: ID
+ if extend[0] == ':no':
+ board["useid"] = '0'
+ elif extend[0] == ':yes':
+ board["useid"] = '1'
+ elif extend[0] == ':force':
+ board["useid"] = '2'
+ elif extend[0] == ':extra':
+ board["useid"] = '3'
+
+ # 2: Slip
+ if extend[1] == ':no':
+ board["slip"] = '0'
+ elif extend[1] == ':yes':
+ board["slip"] = '1'
+ elif extend[1] == ':domain':
+ board["slip"] = '2'
+ elif extend[1] == ':verbose':
+ board["slip"] = '3'
+ elif extend[1] == ':country':
+ board["countrycode"] = '1'
+ elif extend[1] == ':all':
+ board["slip"] = '3'
+ board["countrycode"] = '1'
+ except IndexError:
+ pass
+
+ # if we are replying, use first post's time
+ if post["parentid"]:
+ tim = parent_post["timestamp"]
+ else:
+ tim = post["timestamp"]
+
+ # make ID hash
+ if board["useid"] != '0':
+ post["timestamp_formatted"] += ' ID:' + iphash(ip, post, tim, board["useid"], mobile, self.environ["HTTP_USER_AGENT"], cap_id, hide_end, (board["countrycode"] in ['1', '2']))
+
+ # use for future file checks
+ xfile = (file or oek_file)
+
+ # textboard inforcements (change it to settings maybe?)
+ if board['board_type'] == '1':
+ if not post["parentid"] and not post["subject"]:
+ raise UserError, _("You must enter a title to create a thread.")
+ if not post["message"]:
+ raise UserError, _("Please enter a message.")
+ else:
+ if not post["parentid"] and not xfile and not noimage:
+ raise UserError, _("You must upload an image first to create a thread.")
+ if not xfile and not post["message"]:
+ raise UserError, _("Please enter a message or upload an image to reply.")
+
+ # check if this post is allowed
+ if post["parentid"]:
+ if file and board['allow_image_replies'] == '0':
+ raise UserError, _("Image replies not allowed.")
+ else:
+ if file and board['allow_images'] == '0':
+ raise UserError, _("No images allowed.")
+
+ # use default values when missing
+ if not post["name"] and not post["tripcode"]:
+ post["name"] = random.choice(board["anonymous"].split('|'))
+ if not post["subject"] and not post["parentid"]:
+ post["subject"] = board["subject"]
+ if not post["message"]:
+ post["message"] = board["message"]
+
+ # process files
+ if oek_file:
+ try:
+ fname = "%s/oek_temp/%s.png" % (Settings.HOME_DIR, oek_file)
+ with open(fname) as f:
+ file = f.read()
+ os.remove(fname)
+ except:
+ raise UserError, "Imposible leer la imagen oekaki."
+
+ if file and not noimage:
+ post = processImage(post, file, t, file_original, (spoil and board['allow_spoilers'] == '1'))
+
+ # slip
+ if board["slip"] != '0':
+ slips = []
+
+ # name
+ if board["slip"] in ['1', '3']:
+ if time.strftime("%H") in ['00', '24'] and time.strftime("%M") == '00' and time.strftime("%S") == '00':
+ host_nick = '000000'
+ else:
+ host_nick = 'sarin'
+
+ if hide_end:
+ host_nick = '★'
+ elif addressIsTor(ip):
+ host_nick = 'onion'
+ else:
+ isps = {'cablevision': 'easy',
+ 'cantv': 'warrior',
+ 'claro': 'america',
+ 'cnet': 'nova',
+ 'copelnet': 'cisneros',
+ 'cps.com': 'silver',
+ 'cybercable': 'bricklayer',
+ 'entel': 'matte',
+ 'eternet': 'stream',
+ 'fibertel': 'roughage',
+ 'geonet': 'thunder',
+ 'gtdinternet': 'casanueva',
+ 'ifxnw': 'effect',
+ 'infinitum': 'telegraph',
+ 'intercable': 'easy',
+ 'intercity': 'cordoba',
+ 'iplannet': 'conquest',
+ 'itcsa.net': 'sarmiento',
+ 'megared': 'clear',
+ 'movistar': 'bell',
+ 'nextel': 'fleet',
+ 'speedy': 'oxygen',
+ 'telecom': 'license',
+ 'telmex': 'slender',
+ 'telnor': 'compass',
+ 'tie.cl': 'bell',
+ 'vtr.net': 'liberty',
+ 'utfsm': 'virgin',
+ }
+ host = getHost(ip)
+
+ if host:
+ for k, v in isps.iteritems():
+ if k in host:
+ host_nick = v
+ break
+
+ slips.append(host_nick)
+
+ # hash
+ if board["slip"] in ['1', '3']:
+ if hide_end:
+ slips.append('-'.join(('****', '****')))
+ elif addressIsTor(ip):
+ slips.append('-'.join(('****', getMD5(os.environ["HTTP_USER_AGENT"])[:4])))
+ else:
+ slips.append('-'.join((getMD5(ip)[:4], getMD5(os.environ["HTTP_USER_AGENT"])[:4])))
+
+ # host
+ if board["slip"] == '2':
+ if hide_end:
+ host = '★'
+ elif addressIsTor(ip):
+ host = 'onion'
+ else:
+ host = getHost(ip)
+ if host:
+ hosts = host.split('.')
+ if len(hosts) > 2:
+ if hosts[-2] in ['ne', 'net', 'com', 'co']:
+ host = '.'.join((hosts[-3], hosts[-2], hosts[-1]))
+ else:
+ host = '.'.join((hosts[-2], hosts[-1]))
+ host = '*.' + host
+ else:
+ iprs = ip.split('.')
+ host = '%s.%s.*.*' % (iprs[0], iprs[1])
+ slips.append(host)
+
+ # IP
+ if board["slip"] == '3':
+ if hide_end:
+ host = '[*.*.*.*]'
+ else:
+ iprs = ip.split('.')
+ host = '[%s.%s.*.*]' % (iprs[0], iprs[1])
+ slips.append(host)
+
+ if slips:
+ post["tripcode"] += " (%s)" % ' '.join(slips)
+
+ # country code
+ if board["countrycode"] == '1':
+ if hide_end or addressIsTor(ip):
+ country = '??'
+ else:
+ country = getCountry(ip)
+ post["name"] += " <em>[%s]</em>" % country
+
+ # set expiration date if necessary
+ if board["maxage"] != '0' and not post["parentid"]:
+ if board["dir"] == '2d':
+ date_format = '%m月%d日'
+ date_format_y = '%Y年%m月'
+ else:
+ date_format = '%d/%m'
+ date_format_y = '%m/%Y'
+ post["expires"] = int(t) + (int(board["maxage"]) * 86400)
+ if int(board["maxage"]) >= 365:
+ date_format = date_format_y
+ post["expires_formatted"] = datetime.datetime.fromtimestamp(post["expires"]).strftime(date_format)
+
+ if not post["parentid"]:
+ # fill with default values if creating a new thread
+ post["length"] = 1
+ post["last"] = post["timestamp"]
+
+ if board["dir"] == 'noticias':
+ # check if there's at least one link
+ if "<a href" not in post["message"]:
+ raise UserError, "Al momento de crear un hilo en esta sección necesitas incluír al menos 1 link como fuente en tu mensaje."
+
+ # insert icon if needed
+ img_src = '<img src="%s" alt="ico" /><br />' % getRandomIco()
+ post["message"] = img_src + post["message"]
+
+ # insert post, then run timThreads to make sure the board doesn't exceed the page limit
+ postid = post.insert()
+
+ # delete threads that have crossed last page
+ trimThreads()
+
+ # fix null references when creating thread
+ if board["board_type"] == '1' and not post["parentid"]:
+ post["message"] = re.compile(r'<a href="/(\w+)/res/0.html/(.+)"').sub(r'<a href="/\1/res/'+str(postid)+r'.html/\2"', post["message"])
+ UpdateDb("UPDATE `posts` SET message = '%s' WHERE boardid = '%s' AND id = '%s'" % (_mysql.escape_string(post["message"]), _mysql.escape_string(board["id"]), _mysql.escape_string(str(postid))))
+
+ # do operations if replying to a thread (bump, autoclose, update cache)
+ logTime("Updating thread")
+ thread_length = None
+ if post["parentid"]:
+ # get length of the thread
+ thread_length = threadNumReplies(post["parentid"])
+
+ # bump if not saged
+ if 'sage' not in post["email"].lower() and parent_post['locked'] != '2':
+ UpdateDb("UPDATE `posts` SET bumped = %d WHERE (`id` = '%s' OR `parentid` = '%s') AND `boardid` = '%s'" % (post["timestamp"], post["parentid"], post["parentid"], board["id"]))
+
+ # check if thread must be closed
+ autoclose_thread(post["parentid"], t, thread_length)
+
+ # update final attributes (length and last post)
+ UpdateDb("UPDATE `posts` SET length = %d, last = %d WHERE `id` = '%s' AND `boardid` = '%s'" % (thread_length, post["timestamp"], post["parentid"], board["id"]))
+
+ # update cache
+ threadUpdated(post["parentid"])
+ else:
+ # create cache for new thread
+ threadUpdated(postid)
+
+ regenerateHome()
+
+ # make page redirect
+ ttaken = timeTaken(_STARTTIME, time.clock())
+ noko = 'noko' in email.lower() or (board["board_type"] == '1')
+
+ # get new post url
+ post_url = make_url(postid, post, parent_post or post, noko, mobile)
+
+ if board['secret'] == '0':
+ # add to recent posts
+ if Settings.ENABLE_RSS:
+ latestAdd(post, thread_length, postid, parent_post)
+ # call discord hook
+ if Settings.ENABLE_DISCORD_HOOK and not post["parentid"]:
+ hook_url = make_url(postid, post, parent_post or post, True, False)
+ discord_hook(post, hook_url)
+
+ return (post_url, ttaken)
+
+ def delete_post(self, boarddir, postid, imageonly, password, mobile=False):
+ # open database
+ OpenDb()
+
+ # set the board
+ board = setBoard(boarddir)
+
+ if board["dir"] == '0':
+ raise UserError, "No se pueden eliminar mensajes en esta sección."
+
+ # check if we have a post id and check it's numeric
+ if not postid:
+ raise UserError, "Selecciona uno o más mensajes a eliminar."
+
+ # make sure we have a password
+ if not password:
+ raise UserError, _("Please enter a password.")
+
+ to_delete = []
+ if isinstance(postid, list):
+ to_delete = [n.value for n in postid]
+ else:
+ to_delete = [postid]
+
+ # delete posts
+ if board['board_type'] == '1' and len(to_delete) == 1:
+ # we should be deleting only one (textboard)
+ # check if it's the last post and delete it completely if it is
+ deltype = '0'
+ post = FetchOne("SELECT `id`, `timestamp`, `parentid` FROM `posts` WHERE `boardid` = %s AND `id` = %s LIMIT 1" % (board["id"], str(to_delete[0])))
+ if post['parentid'] != '0':
+ op = get_parent_post(post['parentid'], board['id'])
+ if op['last'] != post['timestamp']:
+ deltype = '1'
+
+ deletePost(to_delete[0], password, deltype, imageonly)
+ latestRemove(post['id'])
+ regenerateHome()
+ else:
+ # delete all checked posts (IB)
+ deleted = 0
+ errors = 0
+ msgs = []
+
+ for pid in to_delete:
+ try:
+ deletePost(pid, password, board['recyclebin'], imageonly)
+ latestRemove(pid)
+ deleted += 1
+ msgs.append('No.%s: Eliminado' % pid)
+ except UserError, message:
+ errors += 1
+ msgs.append('No.%s: %s' % (pid, message))
+
+ # regenerate home
+ if deleted:
+ regenerateHome()
+
+ # show errors, if any
+ if errors:
+ raise UserError, 'No todos los mensajes pudieron ser eliminados.<br />' + '<br />'.join(msgs)
+
+ # redirect
+ if imageonly:
+ self.output += '<html xmlns="http://www.w3.org/1999/xhtml"><body><meta http-equiv="refresh" content="0;url=%s/" /><p>%s</p></body></html>' % (("/cgi/mobile/" if mobile else Settings.BOARDS_URL) + board["dir"], _("File deleted successfully."))
+ else:
+ self.output += '<html xmlns="http://www.w3.org/1999/xhtml"><body><meta http-equiv="refresh" content="0;url=%s/" /><p>%s</p></body></html>' % (("/cgi/mobile/" if mobile else Settings.BOARDS_URL) + board["dir"], _("Post deleted successfully."))
+
+ def report(self, ip, boarddir, postid, reason, txt, postshow):
+ # don't allow if the report system is off
+ if not Settings.REPORTS_ENABLE:
+ raise UserError, _('Report system is deactivated.')
+
+ # if there's not a reason, show the report page
+ if reason is None:
+ self.output += renderTemplate("report.html", {'finished': False, 'postshow': postshow, 'txt': txt})
+ return
+
+ # check reason
+ if not reason:
+ raise UserError, _("Enter a reason.")
+ if len(reason) > 100:
+ raise UserError, _("Text too long.")
+
+ # open database
+ OpenDb()
+
+ # set the board we're in
+ board = setBoard(boarddir)
+
+ # check if he's banned
+ if addressIsBanned(ip, board["dir"]):
+ raise UserError, _("You're banned.")
+
+ # check if post exists
+ post = FetchOne("SELECT `id`, `parentid`, `ip` FROM `posts` WHERE `id` = '%s' AND `boardid` = '%s'" % (_mysql.escape_string(str(postid)), _mysql.escape_string(board['id'])))
+ if not post:
+ raise UserError, _("Post doesn't exist.")
+
+ # generate link
+ if board["board_type"] == '1':
+ parent_post = get_parent_post(post["parentid"], board["id"])
+ link = "/%s/read/%s/%s" % (board["dir"], parent_post["timestamp"], postshow)
+ else:
+ link = "/%s/res/%s.html#%s" % (board["dir"], post["parentid"], post["id"])
+
+ # insert report
+ t = time.time()
+ message = cgi.escape(self.formdata["reason"]).strip()[0:8000]
+ message = message.replace("\n", "<br />")
+
+ UpdateDb("INSERT INTO `reports` (board, postid, parentid, link, ip, reason, reporterip, timestamp, timestamp_formatted) VALUES ('%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s')" % (board["dir"], post['id'], post['parentid'], link, post['ip'], _mysql.escape_string(message), _mysql.escape_string(self.environ["REMOTE_ADDR"]), str(t), formatTimestamp(t)))
+ self.output = renderTemplate("report.html", {'finished': True})
+
+ def stats(self):
+ import json, math, platform
+ try:
+ with open('stats.json', 'r') as f:
+ out = json.load(f)
+ except ValueError:
+ out = {'t': 0}
+
+ regenerated = False
+ if (time.time() - out['t']) > 3600:
+ regenerated = True
+
+ # open database
+ OpenDb()
+
+ # 1 week = 604800
+ query_day = FetchAll("SELECT DATE_FORMAT(FROM_UNIXTIME(FLOOR((timestamp-10800)/86400)*86400+86400), \"%Y-%m-%d\"), COUNT(1), COUNT(IF(parentid=0, 1, NULL)) "
+ "FROM posts "
+ "WHERE (timestamp-10800) > (UNIX_TIMESTAMP()-604800) AND (IS_DELETED = 0 OR IS_DELETED = 3) "
+ "GROUP BY FLOOR((timestamp-10800)/86400) "
+ "ORDER BY FLOOR((timestamp-10800)/86400)", 0)
+
+ query_count = FetchOne("SELECT COUNT(1), COUNT(NULLIF(file, '')), VERSION() FROM posts", 0)
+ total = int(query_count[0])
+ total_files = int(query_count[1])
+ mysql_ver = query_count[2]
+
+ archive_count = FetchOne("SELECT SUM(length) FROM archive", 0)
+ total_archived = int(archive_count[0])
+
+ days = []
+ for date, count, threads in query_day[1:]:
+ days.append( (date, count, threads) )
+
+ query_b = FetchAll("SELECT id, dir, name FROM boards WHERE boards.secret = 0", 0)
+
+ boards = []
+ totalp = 0
+ for id, dir, longname in query_b:
+ bposts = FetchOne("SELECT COUNT(1) FROM posts "
+ "WHERE '"+str(id)+"' = posts.boardid AND timestamp > ( UNIX_TIMESTAMP(DATE(NOW())) - 2419200 )", 0)
+ boards.append( (dir, longname, int(bposts[0])) )
+ totalp += int(bposts[0])
+
+ boards = sorted(boards, key=lambda boards: boards[2], reverse=True)
+
+ boards_percent = []
+ for dir, longname, bposts in boards:
+ if bposts > 0:
+ boards_percent.append( (dir, longname, '{0:.2f}'.format( float(bposts)*100/totalp ), int(bposts) ) )
+ else:
+ boards_percent.append( (dir, longname, '0.00', '0' ) )
+
+ #posts = FetchAll("SELECT `parentid`, `boardid` FROM `posts` INNER JOIN `boards` ON posts.boardid = boards.id WHERE posts.parentid<>0 AND posts.timestamp>(UNIX_TIMESTAMP()-86400) AND boards.secret=0 ORDER BY `parentid`")
+ #threads = {}
+ #for post in posts:
+ # if post["parentid"] in threads:
+ # threads[post["parentid"]] += 1
+ # else:
+ # threads[post["parentid"]] = 1
+
+ python_version = platform.python_version()
+ if self.environ.get('FCGI_FORCE_CGI', 'N').upper().startswith('Y'):
+ python_version += " (CGI)"
+ else:
+ python_version += " (FastCGI)"
+
+ out = {
+ "uname": platform.uname(),
+ "python_ver": python_version,
+ "python_impl": platform.python_implementation(),
+ "python_build": platform.python_build()[1],
+ "python_compiler": platform.python_compiler(),
+ "mysql_ver": mysql_ver,
+ "tenjin_ver": tenjin.__version__,
+ "weabot_ver": __version__,
+ "days": days,
+ "boards": boards,
+ "boards_percent": boards_percent,
+ "total": total,
+ "total_files": total_files,
+ "total_archived": total_archived,
+ "t": timestamp(),
+ "tz": Settings.TIME_ZONE,
+ }
+ with open('stats.json', 'w') as f:
+ json.dump(out, f)
+
+ out['timestamp'] = re.sub(r"\(...\)", " ", formatTimestamp(out['t']))
+ out['regenerated'] = regenerated
+ self.output = renderTemplate("stats.html", out)
+ #self.headers = [("Content-Type", "application/json")]
+
+if __name__ == "__main__":
+ from fcgi import WSGIServer
+
+ # Psyco is not required, however it will be used if available
+ try:
+ import psyco
+ logTime("Psyco se ha instalado")
+ psyco.bind(tenjin.helpers.to_str)
+ psyco.bind(weabot.run, 2)
+ psyco.bind(getFormData)
+ psyco.bind(setCookie)
+ psyco.bind(threadUpdated)
+ psyco.bind(processImage)
+ except:
+ pass
+
+ WSGIServer(weabot).run()
+