cms.py 13 KB

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