cms.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861
  1. #
  2. # cms.py - simple WSGI/Python based CMS script
  3. #
  4. # Copyright (C) 2011-2012 Michael Buesch <m@bues.ch>
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 2 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. import sys
  19. import os
  20. from stat import S_ISDIR
  21. from datetime import datetime
  22. import re
  23. import Image
  24. from StringIO import StringIO
  25. import urllib
  26. import cgi
  27. UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
  28. LOWERCASE = 'abcdefghijklmnopqrstuvwxyz'
  29. NUMBERS = '0123456789'
  30. def findNot(string, template, idx=0):
  31. while idx < len(string):
  32. if string[idx] not in template:
  33. return idx
  34. idx += 1
  35. return -1
  36. def htmlEscape(string):
  37. return cgi.escape(string, True)
  38. def stringBool(string, default=False):
  39. s = string.lower()
  40. if s in ("true", "yes", "on"):
  41. return True
  42. if s in ("false", "no", "off"):
  43. return False
  44. try:
  45. return bool(int(s, 10))
  46. except ValueError:
  47. return default
  48. PATHSEP = "/"
  49. def mkpath(*path_elements):
  50. return PATHSEP.join(path_elements)
  51. def f_exists(*path_elements):
  52. try:
  53. os.stat(mkpath(*path_elements))
  54. except OSError:
  55. return False
  56. return True
  57. def f_exists_nonempty(*path_elements):
  58. if f_exists(*path_elements):
  59. return bool(f_read(*path_elements).strip())
  60. return False
  61. def f_read(*path_elements):
  62. try:
  63. fd = open(mkpath(*path_elements), "rb")
  64. data = fd.read()
  65. fd.close()
  66. return data
  67. except IOError:
  68. return ""
  69. def f_read_int(*path_elements):
  70. data = f_read(*path_elements)
  71. try:
  72. return int(data.strip(), 10)
  73. except ValueError:
  74. return None
  75. def f_mtime(*path_elements):
  76. try:
  77. return datetime.utcfromtimestamp(os.stat(mkpath(*path_elements)).st_mtime)
  78. except OSError:
  79. raise CMSException(404)
  80. def f_mtime_nofail(*path_elements):
  81. try:
  82. return f_mtime(*path_elements)
  83. except CMSException:
  84. return datetime.utcnow()
  85. def f_subdirList(*path_elements):
  86. def dirfilter(dentry):
  87. if dentry.startswith("."):
  88. return False # Omit ".", ".." and hidden entries
  89. try:
  90. if not S_ISDIR(os.stat(mkpath(path, dentry)).st_mode):
  91. return False
  92. except OSError:
  93. return False
  94. return True
  95. path = mkpath(*path_elements)
  96. try:
  97. return filter(dirfilter, os.listdir(path))
  98. except OSError:
  99. return []
  100. def validateSafePathComponent(pcomp):
  101. # Validate a path component. Avoid any directory change.
  102. # Raises CMSException on failure.
  103. if pcomp.startswith('.'):
  104. # No ".", ".." and hidden files.
  105. raise CMSException(404)
  106. validChars = LOWERCASE + UPPERCASE + NUMBERS + "-_."
  107. if [ c for c in pcomp if c not in validChars ]:
  108. raise CMSException(404)
  109. return pcomp
  110. def validateSafePath(path):
  111. # Validate a path. Avoid going back in the hierarchy (. and ..)
  112. # Raises CMSException on failure.
  113. for pcomp in path.split(PATHSEP):
  114. validateSafePathComponent(pcomp)
  115. return path
  116. validateName = validateSafePathComponent
  117. class CMSException(Exception):
  118. __stats = {
  119. 301 : "Moved Permanently",
  120. 400 : "Bad Request",
  121. 404 : "Not Found",
  122. 405 : "Method Not Allowed",
  123. 409 : "Conflict",
  124. 500 : "Internal Server Error",
  125. }
  126. def __init__(self, httpStatusCode=500, message=""):
  127. try:
  128. httpStatus = self.__stats[httpStatusCode]
  129. except KeyError:
  130. httpStatusCode = 500
  131. httpStatus = self.__stats[httpStatusCode]
  132. self.httpStatusCode = httpStatusCode
  133. self.httpStatus = "%d %s" % (httpStatusCode, httpStatus)
  134. self.message = message
  135. def getHttpHeaders(self, resolveCallback):
  136. return ()
  137. def getHtmlHeader(self, db):
  138. return ""
  139. def getHtmlBody(self, db):
  140. return db.getString('http-error-page',
  141. '<p style="font-size: large;">%s</p>' %\
  142. self.httpStatus)
  143. class CMSException301(CMSException):
  144. # "Moved Permanently" exception
  145. def __init__(self, newUrl):
  146. CMSException.__init__(self, 301, newUrl)
  147. def url(self):
  148. return self.message
  149. def getHttpHeaders(self, resolveCallback):
  150. return ( ('Location', resolveCallback(self.url())), )
  151. def getHtmlHeader(self, db):
  152. return '<meta http-equiv="refresh" content="0; URL=%s" />' %\
  153. self.url()
  154. def getHtmlBody(self, db):
  155. return '<p style="font-size: large;">' \
  156. 'Moved permanently to ' \
  157. '<a href="%s">%s</a>' \
  158. '</p>' %\
  159. (self.url(), self.url())
  160. class CMSDatabase(object):
  161. def __init__(self, basePath):
  162. self.pageBase = mkpath(basePath, "pages")
  163. self.macroBase = mkpath(basePath, "macros")
  164. self.stringBase = mkpath(basePath, "strings")
  165. def getPage(self, groupname, pagename):
  166. path = mkpath(self.pageBase,
  167. validateName(groupname),
  168. validateName(pagename))
  169. redirect = f_read(path, "redirect").strip()
  170. if redirect:
  171. raise CMSException301(redirect)
  172. title = f_read(path, "title").strip()
  173. if not title:
  174. title = f_read(path, "nav_label").strip()
  175. data = f_read(path, "content.html")
  176. stamp = f_mtime_nofail(path, "content.html")
  177. return (title, data, stamp)
  178. def getGroupNames(self):
  179. # Returns list of (groupname, navlabel, prio)
  180. res = []
  181. for groupname in f_subdirList(self.pageBase):
  182. path = mkpath(self.pageBase, groupname)
  183. if f_exists(path, "hidden"):
  184. continue
  185. navlabel = f_read(path, "nav_label").strip()
  186. prio = f_read_int(path, "priority")
  187. res.append( (groupname, navlabel, prio) )
  188. return res
  189. def getPageNames(self, groupname):
  190. # Returns list of (pagename, navlabel, prio)
  191. res = []
  192. gpath = mkpath(self.pageBase, validateName(groupname))
  193. for pagename in f_subdirList(gpath):
  194. path = mkpath(gpath, pagename)
  195. if f_exists(path, "hidden") or \
  196. f_exists_nonempty(path, "redirect"):
  197. continue
  198. navlabel = f_read(path, "nav_label").strip()
  199. prio = f_read_int(path, "priority")
  200. res.append( (pagename, navlabel, prio) )
  201. return res
  202. def getMacro(self, name):
  203. data = f_read(self.macroBase, validateName(name))
  204. return '\n'.join( l for l in data.splitlines() if l )
  205. def getString(self, name, default=None):
  206. name = validateName(name)
  207. string = f_read(self.stringBase, name).strip()
  208. if string:
  209. return string
  210. return name if default is None else default
  211. class CMSStatementResolver(object):
  212. # Macro argument expansion: $1, $2, $3...
  213. macro_arg_re = re.compile(r'\$(\d+)', re.DOTALL)
  214. # Valid characters for variable names (without the leading $)
  215. VARNAME_CHARS = UPPERCASE + '_'
  216. __genericVars = {
  217. "DOMAIN" : lambda self, n: self.cms.domain,
  218. "CMS_BASE" : lambda self, n: self.cms.urlBase,
  219. "IMAGES_DIR" : lambda self, n: self.cms.imagesDir,
  220. "THUMBS_DIR" : lambda self, n: self.cms.urlBase + "/__thumbs",
  221. "DEBUG" : lambda self, n: "1" if self.cms.debug else "",
  222. "__DUMPVARS__" : lambda self, n: self.__dumpVars(),
  223. }
  224. class StackElem(object): # Call stack element
  225. def __init__(self, name):
  226. self.name = name
  227. self.lineno = 1
  228. def __init__(self, cms):
  229. self.cms = cms
  230. self.__reset()
  231. def __reset(self, variables={}):
  232. self.variables = variables.copy()
  233. self.variables.update(self.__genericVars)
  234. self.callStack = [ self.StackElem("content.html") ]
  235. def __stmtError(self, msg):
  236. pfx = ""
  237. if self.cms.debug:
  238. pfx = "%s:%d: " %\
  239. (self.callStack[-1].name,
  240. self.callStack[-1].lineno)
  241. raise CMSException(500, pfx + msg)
  242. def __expandVariable(self, name):
  243. try:
  244. value = self.variables[name]
  245. try:
  246. value = value(self, name)
  247. except (TypeError), e:
  248. pass
  249. return str(value)
  250. except (KeyError, TypeError), e:
  251. return ""
  252. def __dumpVars(self, force=False):
  253. if not force and not self.cms.debug:
  254. return ""
  255. ret, names = [], self.variables.keys()
  256. names.sort()
  257. for name in names:
  258. if name == "__DUMPVARS__":
  259. value = "-- variable dump --"
  260. else:
  261. value = self.__expandVariable(name)
  262. sep = "\t" * (3 - len(name) // 8)
  263. ret.append("%s%s=> %s" % (name, sep, value))
  264. return "\n".join(ret)
  265. __escapedChars = ('\\', ',', '@', '$', '(', ')')
  266. @classmethod
  267. def escape(cls, data):
  268. for c in cls.__escapedChars:
  269. data = data.replace(c, '\\' + c)
  270. return data
  271. @classmethod
  272. def unescape(cls, data):
  273. for c in cls.__escapedChars:
  274. data = data.replace('\\' + c, c)
  275. return data
  276. # Parse statement arguments.
  277. # Returns (consumed-characters-count, arguments) tuple.
  278. def __parseArguments(self, d, strip=False):
  279. arguments, cons = [], 0
  280. while cons < len(d):
  281. c, arg = self.__expandRecStmts(d[cons:], ',)')
  282. cons += c
  283. arguments.append(arg.strip() if strip else arg)
  284. if cons <= 0 or d[cons - 1] == ')':
  285. break
  286. return cons, arguments
  287. # Statement: $(if CONDITION, THEN, ELSE)
  288. # Statement: $(if CONDITION, THEN)
  289. # Returns THEN if CONDITION is nonempty after stripping whitespace.
  290. # Returns ELSE otherwise.
  291. def __stmt_if(self, d):
  292. cons, args = self.__parseArguments(d)
  293. if len(args) != 2 and len(args) != 3:
  294. self.__stmtError("IF: invalid args")
  295. condition, b_then = args[0], args[1]
  296. b_else = args[2] if len(args) == 3 else ""
  297. result = b_then if condition.strip() else b_else
  298. return cons, result
  299. def __do_compare(self, d, invert):
  300. cons, args = self.__parseArguments(d, strip=True)
  301. result = reduce(lambda a, b: a and b == args[0],
  302. args[1:], True)
  303. result = not result if invert else result
  304. return cons, (args[-1] if result else "")
  305. # Statement: $(eq A, B, ...)
  306. # Returns the last argument, if all stripped arguments are equal.
  307. # Returns an empty string otherwise.
  308. def __stmt_eq(self, d):
  309. return self.__do_compare(d, False)
  310. # Statement: $(ne A, B, ...)
  311. # Returns the last argument, if not all stripped arguments are equal.
  312. # Returns an empty string otherwise.
  313. def __stmt_ne(self, d):
  314. return self.__do_compare(d, True)
  315. # Statement: $(and A, B, ...)
  316. # Returns A, if all stripped arguments are non-empty strings.
  317. # Returns an empty string otherwise.
  318. def __stmt_and(self, d):
  319. cons, args = self.__parseArguments(d, strip=True)
  320. return cons, (args[0] if all(args) else "")
  321. # Statement: $(or A, B, ...)
  322. # Returns the first stripped non-empty argument.
  323. # Returns an empty string, if there is no non-empty argument.
  324. def __stmt_or(self, d):
  325. cons, args = self.__parseArguments(d, strip=True)
  326. nonempty = [ a for a in args if a ]
  327. return cons, (nonempty[0] if nonempty else "")
  328. # Statement: $(not A)
  329. # Returns 1, if A is an empty string after stripping.
  330. # Returns an empty string, if A is a non-empty stripped string.
  331. def __stmt_not(self, d):
  332. cons, args = self.__parseArguments(d, strip=True)
  333. if len(args) != 1:
  334. self.__stmtError("NOT: invalid args")
  335. return cons, ("" if args[0] else "1")
  336. # Statement: $(assert A, ...)
  337. # Raises a 500-assertion-failed exception, if any argument
  338. # is empty after stripping.
  339. # Returns an empty string, otherwise.
  340. def __stmt_assert(self, d):
  341. cons, args = self.__parseArguments(d, strip=True)
  342. if not all(args):
  343. self.__stmtError("ASSERT: failed")
  344. return cons, ""
  345. # Statement: $(strip STRING)
  346. # Strip whitespace at the start and at the end of the string.
  347. def __stmt_strip(self, d):
  348. cons, args = self.__parseArguments(d, strip=True)
  349. return cons, "".join(args)
  350. # Statement: $(sanitize STRING)
  351. # Sanitize a string.
  352. # Replaces all non-alphanumeric characters by an underscore. Forces lower-case.
  353. def __stmt_sanitize(self, d):
  354. cons, args = self.__parseArguments(d)
  355. string = "_".join(args)
  356. validChars = LOWERCASE + NUMBERS
  357. string = string.lower()
  358. string = "".join( c if c in validChars else '_' for c in string )
  359. string = re.sub(r'_+', '_', string).strip('_')
  360. return cons, string
  361. # Statement: $(file_exists RELATIVE_PATH)
  362. # Statement: $(file_exists RELATIVE_PATH, DOES_NOT_EXIST)
  363. # Checks if a file exists relative to the wwwPath base.
  364. # Returns the path, if the file exists or an empty string if it doesn't.
  365. # If DOES_NOT_EXIST is specified, it returns this if the file doesn't exist.
  366. def __stmt_fileExists(self, d):
  367. cons, args = self.__parseArguments(d)
  368. if len(args) != 1 and len(args) != 2:
  369. self.__stmtError("FILE_EXISTS: invalid args")
  370. relpath, enoent = args[0], args[1] if len(args) == 2 else ""
  371. try:
  372. exists = f_exists(self.cms.wwwPath,
  373. validateSafePath(relpath))
  374. except (CMSException), e:
  375. exists = False
  376. return cons, (relpath if exists else enoent)
  377. # Statement: $(file_mdatet RELATIVE_PATH)
  378. # Statement: $(file_mdatet RELATIVE_PATH, DOES_NOT_EXIST)
  379. # Returns the file modification time.
  380. # If the file does not exist, it returns DOES_NOT_EXIST or and empty string.
  381. # RELATIVE_PATH is relative to wwwPath.
  382. def __stmt_fileModDateTime(self, d):
  383. cons, args = self.__parseArguments(d)
  384. if len(args) != 1 and len(args) != 2:
  385. self.__stmtError("FILE_MDATET: invalid args")
  386. relpath, enoent = args[0], args[1] if len(args) == 2 else ""
  387. try:
  388. stamp = f_mtime(self.cms.wwwPath,
  389. validateSafePath(relpath))
  390. except (CMSException), e:
  391. return cons, enoent
  392. return cons, stamp.strftime("%d %B %Y %H:%M (UTC)")
  393. __handlers = {
  394. "$(if" : __stmt_if,
  395. "$(eq" : __stmt_eq,
  396. "$(ne" : __stmt_ne,
  397. "$(and" : __stmt_and,
  398. "$(or" : __stmt_or,
  399. "$(not" : __stmt_not,
  400. "$(assert" : __stmt_assert,
  401. "$(strip" : __stmt_strip,
  402. "$(sanitize" : __stmt_sanitize,
  403. "$(file_exists" : __stmt_fileExists,
  404. "$(file_mdatet" : __stmt_fileModDateTime,
  405. }
  406. def __doMacro(self, macroname, d):
  407. if len(self.callStack) > 16:
  408. raise CMSException(500, "Exceed macro call stack depth")
  409. cons, arguments = self.__parseArguments(d, strip=True)
  410. # Fetch the macro data from the database
  411. macrodata = None
  412. try:
  413. macrodata = self.cms.db.getMacro(macroname[1:])
  414. except (CMSException), e:
  415. if e.httpStatusCode == 404:
  416. raise CMSException(500,
  417. "Macro name '%s' contains "
  418. "invalid characters" % macroname)
  419. if not macrodata:
  420. return cons, "" # Macro does not exist.
  421. # Expand the macro arguments ($1, $2, $3, ...)
  422. def expandArg(match):
  423. nr = int(match.group(1), 10)
  424. if nr >= 1 and nr <= len(arguments):
  425. return arguments[nr - 1]
  426. return macroname if nr == 0 else ""
  427. macrodata = self.macro_arg_re.sub(expandArg, macrodata)
  428. # Resolve statements and recursive macro calls
  429. self.callStack.append(self.StackElem(macroname))
  430. macrodata = self.__resolve(macrodata)
  431. self.callStack.pop()
  432. return cons, macrodata
  433. def __expandRecStmts(self, d, stopchars=""):
  434. # Recursively expand statements and macro calls
  435. ret, i = [], 0
  436. while i < len(d):
  437. cons, res = 1, d[i]
  438. if d[i] == '\\': # Escaped characters
  439. # Keep escapes. They are removed later.
  440. if i + 1 < len(d) and\
  441. d[i + 1] in self.__escapedChars:
  442. res = d[i:i+2]
  443. i += 1
  444. elif d[i] == '\n':
  445. self.callStack[-1].lineno += 1
  446. elif d.startswith('<!---', i): # Comment
  447. end = d.find('--->', i)
  448. if end > i:
  449. strip_nl = 0
  450. # If comment is on a line of its own,
  451. # remove the line.
  452. if (i == 0 or d[i - 1] == '\n') and\
  453. (end + 4 < len(d) and d[end + 4] == '\n'):
  454. strip_nl = 1
  455. cons, res = end - i + 4 + strip_nl, ""
  456. elif d[i] in stopchars: # Stop character
  457. i += 1
  458. break
  459. elif d[i] == '@': # Macro call
  460. end = d.find('(', i)
  461. if end > i:
  462. cons, res = self.__doMacro(
  463. d[i:end],
  464. d[end+1:])
  465. i = end + 1
  466. elif d.startswith('$(', i): # Statement
  467. h = lambda _self, x: (cons, res) # nop
  468. end = d.find(' ', i)
  469. if end > i:
  470. try:
  471. h = self.__handlers[d[i:end]]
  472. i = end + 1
  473. except KeyError: pass
  474. cons, res = h(self, d[i:])
  475. elif d[i] == '$': # Variable
  476. end = findNot(d, self.VARNAME_CHARS, i + 1)
  477. if end > i + 1:
  478. res = self.__expandVariable(d[i+1:end])
  479. cons = end - i
  480. ret.append(res)
  481. i += cons
  482. if stopchars and i >= len(d) and d[-1] not in stopchars:
  483. self.__stmtError("Unterminated statement")
  484. return i, "".join(ret)
  485. def __resolve(self, data):
  486. # Expand recursive statements
  487. unused, data = self.__expandRecStmts(data)
  488. # Remove escapes
  489. data = self.unescape(data)
  490. return data
  491. def resolve(self, data, variables={}):
  492. self.__reset(variables)
  493. return self.__resolve(data)
  494. class CMSQuery(object):
  495. def __init__(self, queryDict):
  496. self.queryDict = queryDict
  497. def get(self, name, default=""):
  498. try:
  499. return self.queryDict[name][-1]
  500. except (KeyError, IndexError), e:
  501. return default
  502. def getInt(self, name, default=0):
  503. try:
  504. return int(self.get(name, str(int(default))), 10)
  505. except (ValueError), e:
  506. return default
  507. def getBool(self, name, default=False):
  508. string = self.get(name, str(bool(default)))
  509. return stringBool(string, default)
  510. class CMS(object):
  511. def __init__(self,
  512. dbPath,
  513. wwwPath,
  514. imagesDir="/images",
  515. domain="example.com",
  516. urlBase="/cms",
  517. cssUrlPath="/cms.css",
  518. debug=False):
  519. # dbPath => Unix path to the database directory.
  520. # wwwPath => Unix path to the static www data.
  521. # imagesDir => Subdirectory path, based on wwwPath, to
  522. # the images directory.
  523. # domain => The site domain name.
  524. # urlBase => URL base component to the HTTP server CMS mapping.
  525. # cssUrlBase => URL subpath to the CSS.
  526. # debug => Enable/disable debugging
  527. self.wwwPath = wwwPath
  528. self.imagesDir = imagesDir
  529. self.domain = domain
  530. self.urlBase = urlBase
  531. self.cssUrlPath = cssUrlPath
  532. self.debug = debug
  533. self.db = CMSDatabase(dbPath)
  534. self.resolver = CMSStatementResolver(self)
  535. def shutdown(self):
  536. pass
  537. def __genHtmlHeader(self, title, additional=""):
  538. header = """<?xml version="1.0" encoding="UTF-8" ?>
  539. <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
  540. <html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
  541. <head>
  542. <meta http-equiv="content-type" content="text/html; charset=utf-8" />
  543. <meta name="robots" content="all" />
  544. <meta name="date" content="%s" />
  545. <meta name="generator" content="WSGI/Python CMS" />
  546. <!--
  547. Generated by "cms.py - simple WSGI/Python based CMS script"
  548. http://bues.ch/gitweb?p=cms.git;a=summary
  549. -->
  550. <title>%s</title>
  551. <link rel="stylesheet" href="%s" type="text/css" />
  552. %s
  553. </head>
  554. <body>
  555. """ %\
  556. (datetime.now().isoformat(), title, self.cssUrlPath, additional)
  557. return header
  558. def __genHtmlFooter(self):
  559. footer = """
  560. </body>
  561. </html>
  562. """
  563. return footer
  564. def __makePageUrl(self, groupname, pagename):
  565. if groupname:
  566. return "/".join( (self.urlBase, groupname, pagename + ".html") )
  567. return self.urlBase
  568. def __makeFullPageUrl(self, groupname, pagename, protocol="http"):
  569. return "%s://%s%s" % (protocol, self.domain,
  570. self.__makePageUrl(groupname, pagename))
  571. def __genHtmlBody(self, groupname, pagename, pageTitle, pageData,
  572. stamp=None,
  573. genSslLinks=True, genCheckerLinks=True):
  574. body = []
  575. # Generate logo / title bar
  576. body.append('<div class="titlebar">')
  577. body.append('\t<div class="logo">')
  578. body.append('\t\t<a href="%s">' % self.urlBase)
  579. body.append('\t\t\t<img alt="logo" src="/logo.png" />')
  580. body.append('\t\t</a>')
  581. body.append('\t</div>')
  582. body.append('\t<div class="title">%s</div>' % pageTitle)
  583. body.append('</div>\n')
  584. # Generate navigation bar
  585. body.append('<div class="navbar">')
  586. body.append('\t<div class="navgroups">')
  587. body.append('\t\t<div class="navhome">')
  588. if not groupname:
  589. body.append('\t\t<div class="navactive">')
  590. body.append('\t\t\t<a href="%s">%s</a>' %\
  591. (self.__makePageUrl(None, None),
  592. self.db.getString("home")))
  593. if not groupname:
  594. body.append('\t\t</div> <!-- class="navactive" -->')
  595. body.append('\t\t</div>')
  596. navGroups = self.db.getGroupNames()
  597. def getNavPrio(element):
  598. name, label, prio = element
  599. if prio is None:
  600. prio = 500
  601. return "%03d_%s" % (prio, label)
  602. navGroups.sort(key=getNavPrio)
  603. for navGroupElement in navGroups:
  604. navgroupname, navgrouplabel, navgroupprio = navGroupElement
  605. body.append('\t\t<div class="navgroup"> '
  606. '<!-- %s -->' % getNavPrio(navGroupElement))
  607. if navgrouplabel:
  608. body.append('\t\t\t<div class="navhead">%s</div>' % navgrouplabel)
  609. body.append('\t\t\t<div class="navelems">')
  610. navPages = self.db.getPageNames(navgroupname)
  611. navPages.sort(key=getNavPrio)
  612. for navPageElement in navPages:
  613. (navpagename, navpagelabel, navpageprio) = navPageElement
  614. body.append('\t\t\t\t<div class="navelem"> '
  615. '<!-- %s -->' %\
  616. getNavPrio(navPageElement))
  617. if navgroupname == groupname and\
  618. navpagename == pagename:
  619. body.append('\t\t\t\t<div class="navactive">')
  620. url = self.__makePageUrl(navgroupname, navpagename)
  621. body.append('\t\t\t\t\t<a href="%s">%s</a>' %\
  622. (url, navpagelabel))
  623. if navgroupname == groupname and\
  624. navpagename == pagename:
  625. body.append('\t\t\t\t</div> <!-- class="navactive" -->')
  626. body.append('\t\t\t\t</div>')
  627. body.append('\t\t\t</div>')
  628. body.append('\t\t</div>')
  629. body.append('\t</div>')
  630. body.append('</div>\n')
  631. body.append('<div class="main">\n') # Main body start
  632. # Page content
  633. body.append('<!-- BEGIN: page content -->')
  634. body.append(pageData)
  635. body.append('<!-- END: page content -->\n')
  636. if stamp:
  637. # Last-modified date
  638. body.append('\t<div class="modifystamp">')
  639. body.append(stamp.strftime('\t\tUpdated: %A %d %B %Y %H:%M (UTC)'))
  640. body.append('\t</div>')
  641. if genSslLinks:
  642. # SSL
  643. body.append('\t<div class="ssl">')
  644. body.append('\t\t<a href="%s">%s</a>' %\
  645. (self.__makeFullPageUrl(groupname, pagename,
  646. protocol="https"),
  647. self.db.getString("ssl-encrypted")))
  648. body.append('\t</div>')
  649. if genCheckerLinks:
  650. # Checker links
  651. pageUrlQuoted = urllib.quote_plus(
  652. self.__makeFullPageUrl(groupname, pagename))
  653. body.append('\t<div class="checker">')
  654. checkerUrl = "http://validator.w3.org/check?"\
  655. "uri=" + pageUrlQuoted + "&amp;"\
  656. "charset=%28detect+automatically%29&amp;"\
  657. "doctype=Inline&amp;group=0&amp;"\
  658. "user-agent=W3C_Validator%2F1.2"
  659. body.append('\t\t<a href="%s">%s</a> /' %\
  660. (checkerUrl, self.db.getString("checker-xhtml")))
  661. checkerUrl = "http://jigsaw.w3.org/css-validator/validator?"\
  662. "uri=" + pageUrlQuoted + "&amp;profile=css3&amp;"\
  663. "usermedium=all&amp;warning=1&amp;"\
  664. "vextwarning=true&amp;lang=en"
  665. body.append('\t\t<a href="%s">%s</a>' %\
  666. (checkerUrl, self.db.getString("checker-css")))
  667. body.append('\t</div>\n')
  668. body.append('</div>\n') # Main body end
  669. return "\n".join(body)
  670. def __parsePagePath(self, path):
  671. path = path.strip().lstrip('/')
  672. for suffix in ('.html', '.htm', '.php'):
  673. if path.endswith(suffix):
  674. path = path[:-len(suffix)]
  675. break
  676. groupname, pagename = '', ''
  677. if path not in ('', 'index'):
  678. path = path.split('/')
  679. if len(path) == 2:
  680. groupname, pagename = path[0], path[1]
  681. if not groupname or not pagename:
  682. raise CMSException(404)
  683. return groupname, pagename
  684. def __getImageThumbnail(self, imagename, query):
  685. width = query.getInt("w", 300)
  686. height = query.getInt("h", 300)
  687. qual = query.getInt("q", 1)
  688. qualities = {
  689. 0 : Image.NEAREST,
  690. 1 : Image.BILINEAR,
  691. 2 : Image.BICUBIC,
  692. 3 : Image.ANTIALIAS,
  693. }
  694. try:
  695. qual = qualities[qual]
  696. except (KeyError), e:
  697. qual = qualities[1]
  698. try:
  699. img = Image.open(mkpath(self.wwwPath, self.imagesDir,
  700. validateSafePathComponent(imagename)))
  701. img.thumbnail((width, height), qual)
  702. output = StringIO()
  703. img.save(output, "JPEG")
  704. data = output.getvalue()
  705. except (IOError), e:
  706. raise CMSException(404)
  707. return data, "image/jpeg"
  708. def __getHtmlPage(self, groupname, pagename, query):
  709. pageTitle, pageData, stamp = self.db.getPage(groupname, pagename)
  710. if not pageData:
  711. raise CMSException(404)
  712. resolverVariables = {
  713. "GROUP" : lambda r, n: groupname,
  714. "PAGE" : lambda r, n: pagename,
  715. }
  716. for k, v in query.queryDict.iteritems():
  717. k, v = k.upper(), v[-1]
  718. resolverVariables["Q_" + k] = CMSStatementResolver.escape(htmlEscape(v))
  719. resolverVariables["QRAW_" + k] = CMSStatementResolver.escape(v)
  720. pageTitle = self.resolver.resolve(pageTitle, resolverVariables)
  721. pageData = self.resolver.resolve(pageData, resolverVariables)
  722. data = [self.__genHtmlHeader(pageTitle)]
  723. data.append(self.__genHtmlBody(groupname, pagename,
  724. pageTitle, pageData, stamp))
  725. data.append(self.__genHtmlFooter())
  726. return "".join(data), "text/html"
  727. def __generate(self, path, query):
  728. groupname, pagename = self.__parsePagePath(path)
  729. if groupname == "__thumbs":
  730. return self.__getImageThumbnail(pagename, query)
  731. return self.__getHtmlPage(groupname, pagename, query)
  732. def get(self, path, query={}):
  733. query = CMSQuery(query)
  734. return self.__generate(path, query)
  735. def post(self, path, query={}):
  736. raise CMSException(405)
  737. def __doGetErrorPage(self, cmsExcept):
  738. resolverVariables = {
  739. "GROUP" : lambda r, n: "__nogroup",
  740. "PAGE" : lambda r, n: "__nopage",
  741. "HTTP_STATUS" : lambda r, n: cmsExcept.httpStatus,
  742. "HTTP_STATUS_CODE" : lambda r, n: str(cmsExcept.httpStatusCode),
  743. "ERROR_MESSAGE" : lambda r, n: CMSStatementResolver.escape(htmlEscape(cmsExcept.message)),
  744. }
  745. pageHeader = cmsExcept.getHtmlHeader(self.db)
  746. pageHeader = self.resolver.resolve(pageHeader, resolverVariables)
  747. pageData = cmsExcept.getHtmlBody(self.db)
  748. pageData = self.resolver.resolve(pageData, resolverVariables)
  749. httpHeaders = cmsExcept.getHttpHeaders(
  750. lambda s: self.resolver.resolve(s, resolverVariables))
  751. data = [self.__genHtmlHeader(cmsExcept.httpStatus,
  752. additional=pageHeader)]
  753. data.append(self.__genHtmlBody('__nogroup', '__nopage',
  754. cmsExcept.httpStatus,
  755. pageData,
  756. genSslLinks=False,
  757. genCheckerLinks=False))
  758. data.append(self.__genHtmlFooter())
  759. return "".join(data), "text/html", httpHeaders
  760. def getErrorPage(self, cmsExcept):
  761. try:
  762. return self.__doGetErrorPage(cmsExcept)
  763. except (CMSException), e:
  764. data = "Error in exception handler: %s %s" % \
  765. (e.httpStatus, e.message)
  766. return data, "text/plain", ()