cms.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. # -*- coding: utf-8 -*-
  2. #
  3. # cms.py - simple WSGI/Python based CMS script
  4. #
  5. # Copyright (C) 2011-2021 Michael Buesch <m@bues.ch>
  6. #
  7. # This program is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation, either version 2 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This program is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License
  18. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  19. #from cms.cython_support cimport * #@cy
  20. from cms.db import *
  21. from cms.exception import *
  22. from cms.formfields import *
  23. from cms.pageident import *
  24. from cms.query import *
  25. from cms.resolver import * #+cimport
  26. from cms.sitemap import *
  27. from cms.util import * #+cimport
  28. import functools
  29. import PIL.Image as Image
  30. import urllib.parse
  31. __all__ = [
  32. "CMS",
  33. ]
  34. class CMS(object):
  35. # Main CMS entry point.
  36. __rootPageIdent = CMSPageIdent()
  37. def __init__(self,
  38. dbPath,
  39. wwwPath,
  40. imagesDir="/images",
  41. domain="example.com",
  42. urlBase="/cms",
  43. cssUrlPath="/cms.css",
  44. debug=False):
  45. # dbPath => Unix path to the database directory.
  46. # wwwPath => Unix path to the static www data.
  47. # imagesDir => Subdirectory path, based on wwwPath, to
  48. # the images directory.
  49. # domain => The site domain name.
  50. # urlBase => URL base component to the HTTP server CMS mapping.
  51. # cssUrlBase => URL subpath to the CSS.
  52. # debug => Enable/disable debugging
  53. self.wwwPath = wwwPath
  54. self.imagesDir = imagesDir
  55. self.domain = domain
  56. self.urlBase = urlBase
  57. self.cssUrlPath = cssUrlPath
  58. self.debug = debug
  59. self.db = CMSDatabase(dbPath)
  60. self.resolver = CMSStatementResolver(self)
  61. def shutdown(self):
  62. pass
  63. def __genHtmlHeader(self, title, additional = ""):
  64. date = datetime.now(dt_timezone.utc).isoformat()
  65. interpreter = "Python" #@nocy
  66. # interpreter = "Cython" #@cy
  67. sitemap = self.urlBase + "/__sitemap.xml"
  68. additional = "\n\t".join(additional.splitlines())
  69. return f"""<?xml version="1.0" encoding="UTF-8" ?>
  70. <!DOCTYPE html>
  71. <html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
  72. <head>
  73. <!--
  74. Generated by "cms.py - simple CMS script"
  75. https://bues.ch/cgit/cms.git
  76. -->
  77. <meta name="generator" content="WSGI/{interpreter} CMS" />
  78. <meta name="date" content="{date}" />
  79. <meta name="robots" content="all" />
  80. <title>{title}</title>
  81. <link rel="stylesheet" href="{self.cssUrlPath}" type="text/css" />
  82. <link rel="sitemap" type="application/xml" title="Sitemap" href="{sitemap}" />
  83. {additional or ''}
  84. </head>
  85. <body>
  86. """
  87. def __genHtmlFooter(self):
  88. footer = """
  89. </body>
  90. </html>
  91. """
  92. return footer
  93. def _genNavElem(self, body, basePageIdent, activePageIdent, indent=0):
  94. if self.db.getNavStop(basePageIdent):
  95. return
  96. subPages = self.db.getSubPages(basePageIdent)
  97. if not subPages:
  98. return
  99. tabs = '\t' + '\t' * indent
  100. if indent > 0:
  101. body.append('%s<div class="navelems">' % tabs)
  102. for pagename, pagelabel, pageprio in subPages:
  103. if not pagelabel:
  104. continue
  105. body.append('%s\t<div class="%s"> '
  106. '<!-- %d -->' % (
  107. tabs,
  108. "navelem" if indent > 0 else "navgroup",
  109. pageprio))
  110. if indent <= 0:
  111. body.append('%s\t\t<div class="navhead">' %\
  112. tabs)
  113. subPageIdent = CMSPageIdent(basePageIdent + [pagename])
  114. isActive = (activePageIdent is not None and
  115. activePageIdent.startswith(subPageIdent))
  116. if isActive:
  117. body.append('%s\t\t<div class="navactive">' %\
  118. tabs)
  119. body.append('%s\t\t<a href="%s">%s</a>' %\
  120. (tabs,
  121. subPageIdent.getUrl(urlBase=self.urlBase),
  122. pagelabel))
  123. if isActive:
  124. body.append('%s\t\t</div> '
  125. '<!-- class="navactive" -->' %\
  126. tabs)
  127. if indent <= 0:
  128. body.append('%s\t\t</div>' % tabs)
  129. self._genNavElem(body, subPageIdent,
  130. activePageIdent, indent + 2)
  131. body.append('%s\t</div>' % tabs)
  132. if indent > 0:
  133. body.append('%s</div>' % tabs)
  134. def __genHtmlBody(self, pageIdent, pageTitle, pageData,
  135. protocol,
  136. stamp=None, genCheckerLinks=True):
  137. body = []
  138. # Generate logo / title bar
  139. body.append('<div class="titlebar">')
  140. body.append('\t<div class="logo">')
  141. body.append('\t\t<a href="%s">' % self.urlBase)
  142. body.append('\t\t\t<img alt="logo" src="/logo.png" />')
  143. body.append('\t\t</a>')
  144. body.append('\t</div>')
  145. body.append('\t<div class="title">%s</div>' % pageTitle)
  146. body.append('</div>\n')
  147. # Generate navigation bar
  148. body.append('<div class="navbar">')
  149. body.append('\t<div class="navgroups">')
  150. body.append('\t\t<div class="navhome">')
  151. rootActive = not pageIdent
  152. if rootActive:
  153. body.append('\t\t<div class="navactive">')
  154. body.append('\t\t\t<a href="%s">%s</a>' %\
  155. (self.__rootPageIdent.getUrl(urlBase=self.urlBase),
  156. self.db.getString("home")))
  157. if rootActive:
  158. body.append('\t\t</div> <!-- class="navactive" -->')
  159. body.append('\t\t</div>')
  160. self._genNavElem(body, self.__rootPageIdent, pageIdent)
  161. body.append('\t</div>')
  162. body.append('</div>\n')
  163. body.append('<div class="main">\n') # Main body start
  164. # Page content
  165. body.append('<!-- BEGIN: page content -->')
  166. body.append(pageData)
  167. body.append('<!-- END: page content -->\n')
  168. if stamp:
  169. # Last-modified date
  170. body.append('\t<div class="modifystamp">')
  171. body.append(stamp.strftime('\t\tUpdated: %A %d %B %Y %H:%M (UTC)'))
  172. body.append('\t</div>')
  173. if protocol != "https":
  174. # SSL
  175. body.append('\t<div class="ssl">')
  176. body.append('\t\t<a href="%s">%s</a>' % (
  177. pageIdent.getUrl("https", self.domain,
  178. self.urlBase),
  179. self.db.getString("ssl-encrypted")))
  180. body.append('\t</div>')
  181. if genCheckerLinks:
  182. # Checker links
  183. pageUrlQuoted = urllib.parse.quote_plus(
  184. pageIdent.getUrl("http", self.domain,
  185. self.urlBase))
  186. body.append('\t<div class="checker">')
  187. checkerUrl = "http://validator.w3.org/check?"\
  188. "uri=" + pageUrlQuoted + "&amp;"\
  189. "charset=%28detect+automatically%29&amp;"\
  190. "doctype=Inline&amp;group=0&amp;"\
  191. "user-agent=W3C_Validator%2F1.2"
  192. body.append('\t\t<a href="%s">%s</a> /' %\
  193. (checkerUrl, self.db.getString("checker-xhtml")))
  194. checkerUrl = "http://jigsaw.w3.org/css-validator/validator?"\
  195. "uri=" + pageUrlQuoted + "&amp;profile=css3&amp;"\
  196. "usermedium=all&amp;warning=1&amp;"\
  197. "vextwarning=true&amp;lang=en"
  198. body.append('\t\t<a href="%s">%s</a>' %\
  199. (checkerUrl, self.db.getString("checker-css")))
  200. body.append('\t</div>\n')
  201. body.append('</div>\n') # Main body end
  202. return "\n".join(body)
  203. def __getSiteMap(self, query, protocol):
  204. sitemap = CMSSiteMap(self.db, self.domain, self.urlBase)
  205. data = sitemap.getSiteMap(self.__rootPageIdent, protocol)
  206. try:
  207. return (data.encode("UTF-8", "strict"),
  208. "text/xml; charset=UTF-8")
  209. except UnicodeError as e:
  210. raise CMSException(500, "Unicode encode error")
  211. @functools.lru_cache(maxsize=2**8)
  212. def __getImageThumbnail(self, imagename, query, protocol):
  213. if not imagename:
  214. raise CMSException(404)
  215. width = query.getInt("w", 300)
  216. height = query.getInt("h", 300)
  217. qual = query.getInt("q", 1)
  218. qualities = {
  219. 0 : Image.NEAREST,
  220. 1 : Image.BILINEAR,
  221. 2 : Image.BICUBIC,
  222. 3 : getattr(Image, "LANCZOS", getattr(Image, "ANTIALIAS", Image.BICUBIC)),
  223. }
  224. try:
  225. qual = qualities[qual]
  226. except (KeyError) as e:
  227. qual = qualities[1]
  228. try:
  229. imgPath = fs.mkpath(self.wwwPath,
  230. self.imagesDir,
  231. CMSPageIdent.validateSafePathComponent(imagename))
  232. with open(imgPath.encode("UTF-8", "strict"), "rb") as fd:
  233. with Image.open(fd) as img:
  234. img.thumbnail((width, height), qual)
  235. with img.convert("RGB") as cimg:
  236. output = BytesIO()
  237. cimg.save(output, "JPEG")
  238. data = output.getvalue()
  239. except (IOError, UnicodeError) as e:
  240. raise CMSException(404)
  241. return data, "image/jpeg"
  242. def __getHtmlPage(self, pageIdent, query, protocol):
  243. pageTitle, pageData, stamp = self.db.getPage(pageIdent)
  244. if not pageData:
  245. raise CMSException(404)
  246. resolverVariables = {
  247. "PROTOCOL" : lambda r, n: protocol,
  248. "PAGEIDENT" : lambda r, n: pageIdent.getUrl(),
  249. "CMS_PAGEIDENT" : lambda r, n: pageIdent.getUrl(urlBase=self.urlBase),
  250. "GROUP" : lambda r, n: pageIdent.get(0),
  251. "PAGE" : lambda r, n: pageIdent.get(1),
  252. }
  253. resolve = self.resolver.resolve
  254. for k, v in query.items():
  255. k = k.upper()
  256. resolverVariables["Q_" + k] = self.resolver.escape(htmlEscape(v))
  257. resolverVariables["QRAW_" + k] = self.resolver.escape(v)
  258. pageTitle = resolve(pageTitle, resolverVariables, pageIdent)
  259. resolverVariables["TITLE"] = lambda r, n: pageTitle
  260. pageData = resolve(pageData, resolverVariables, pageIdent)
  261. extraHeaders = resolve(self.db.getHeaders(pageIdent),
  262. resolverVariables, pageIdent)
  263. data = [self.__genHtmlHeader(pageTitle, extraHeaders)]
  264. data.append(self.__genHtmlBody(pageIdent,
  265. pageTitle, pageData,
  266. protocol, stamp))
  267. data.append(self.__genHtmlFooter())
  268. try:
  269. return ("".join(data).encode("UTF-8", "strict"),
  270. "application/xhtml+xml; charset=UTF-8")
  271. except UnicodeError as e:
  272. raise CMSException(500, "Unicode encode error")
  273. def __get(self, path, query, protocol):
  274. pageIdent = CMSPageIdent.parse(path)
  275. self.db.beginSession()
  276. firstIdent = pageIdent.get(0, allowSysNames=True)
  277. if firstIdent == "__thumbs":
  278. return self.__getImageThumbnail(pageIdent.get(1), query, protocol)
  279. elif firstIdent in ("__sitemap", "__sitemap.xml"):
  280. return self.__getSiteMap(query, protocol)
  281. return self.__getHtmlPage(pageIdent, query, protocol)
  282. def get(self, path, query={}, protocol="http"):
  283. query = CMSQuery(query)
  284. return self.__get(path, query, protocol)
  285. def __post(self, path, query, body, bodyType, protocol):
  286. pageIdent = CMSPageIdent.parse(path)
  287. self.db.beginSession()
  288. postHandler = self.db.getPostHandler(pageIdent)
  289. if postHandler is None:
  290. raise CMSException(405)
  291. formFields = CMSFormFields(body, bodyType)
  292. try:
  293. ret = postHandler(formFields, query, body, bodyType, protocol)
  294. except CMSException as e:
  295. raise e
  296. except Exception as e:
  297. msg = ""
  298. if self.debug:
  299. msg = " " + str(e)
  300. msg = msg.encode("UTF-8", "ignore")
  301. return (b"Failed to run POST handler." + msg,
  302. "text/plain")
  303. if ret is None:
  304. return self.__get(path, query, protocol)
  305. assert isinstance(ret, tuple) and len(ret) == 2, "post() return is not 2-tuple."
  306. assert isinstance(ret[0], (bytes, bytearray)), "post()[0] is not bytes."
  307. assert isinstance(ret[1], str), "post()[1] is not str."
  308. return ret
  309. def post(self, path, query={},
  310. body=b"", bodyType="text/plain",
  311. protocol="http"):
  312. query = CMSQuery(query)
  313. return self.__post(path, query, body, bodyType, protocol)
  314. def __doGetErrorPage(self, cmsExcept, protocol):
  315. resolverVariables = {
  316. "PROTOCOL" : lambda r, n: protocol,
  317. "GROUP" : lambda r, n: "_nogroup_",
  318. "PAGE" : lambda r, n: "_nopage_",
  319. "HTTP_STATUS" : lambda r, n: cmsExcept.httpStatus,
  320. "HTTP_STATUS_CODE" : lambda r, n: str(cmsExcept.httpStatusCode),
  321. "ERROR_MESSAGE" : lambda r, n: self.resolver.escape(htmlEscape(cmsExcept.message)),
  322. }
  323. pageHeader = cmsExcept.getHtmlHeader(self.db)
  324. pageHeader = self.resolver.resolve(pageHeader, resolverVariables)
  325. pageData = cmsExcept.getHtmlBody(self.db)
  326. pageData = self.resolver.resolve(pageData, resolverVariables)
  327. httpHeaders = cmsExcept.getHttpHeaders(
  328. lambda s: self.resolver.resolve(s, resolverVariables))
  329. data = [self.__genHtmlHeader(cmsExcept.httpStatus,
  330. additional=pageHeader)]
  331. data.append(self.__genHtmlBody(CMSPageIdent(("_nogroup_", "_nopage_")),
  332. cmsExcept.httpStatus,
  333. pageData,
  334. protocol,
  335. genCheckerLinks=False))
  336. data.append(self.__genHtmlFooter())
  337. return "".join(data), "application/xhtml+xml; charset=UTF-8", httpHeaders
  338. def getErrorPage(self, cmsExcept, protocol="http"):
  339. try:
  340. data, mime, headers = self.__doGetErrorPage(cmsExcept, protocol)
  341. except (CMSException) as e:
  342. data = "Error in exception handler: %s %s" % \
  343. (e.httpStatus, e.message)
  344. mime, headers = "text/plain; charset=UTF-8", ()
  345. try:
  346. return data.encode("UTF-8", "strict"), mime, headers
  347. except UnicodeError as e:
  348. # Whoops. All is lost.
  349. raise CMSException(500, "Unicode encode error")