CAT-SOOP is a flexible, programmable learning management system based on the Python programming language. https://catsoop.mit.edu
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

882 lines
30 KiB

  1. # This file is part of CAT-SOOP
  2. # Copyright (c) 2011-2020 by The CAT-SOOP Developers <catsoop-dev@mit.edu>
  3. #
  4. # This program is free software: you can redistribute it and/or modify it under
  5. # the terms of the GNU Affero General Public License as published by the Free
  6. # Software Foundation, either version 3 of the License, or (at your option) any
  7. # later version.
  8. #
  9. # This program is distributed in the hope that it will be useful, but WITHOUT
  10. # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
  11. # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
  12. # details.
  13. #
  14. # You should have received a copy of the GNU Affero General Public License
  15. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  16. """
  17. Methods for handling requests, or for routing them to the proper handlers
  18. """
  19. import os
  20. import cgi
  21. import string
  22. import hashlib
  23. import colorsys
  24. import mimetypes
  25. import traceback
  26. import urllib.parse
  27. from email.utils import formatdate
  28. from . import lti
  29. from . import auth
  30. from . import time
  31. from . import util
  32. from . import tutor
  33. from . import loader
  34. from . import errors
  35. from . import session
  36. from . import language
  37. from . import debug_log
  38. from . import base_context
  39. _nodoc = {"CSFormatter", "formatdate", "dict_from_cgi_form", "LOGGER", "md5"}
  40. LOGGER = debug_log.LOGGER
  41. class CSFormatter(string.Formatter):
  42. def get_value(self, key, args, kwargs):
  43. try:
  44. return super().get_value(key, args, kwargs)
  45. except (IndexError, KeyError):
  46. return ""
  47. def redirect(location):
  48. """
  49. Generate HTTP response that redirects the user to the specified location
  50. **Parameters:**
  51. * `location`: the location the user should be redirected to
  52. **Returns:** a 3-tuple `(response_code, headers, content)` as expected by
  53. `catsoop.wsgi.application`
  54. """
  55. return ("302", "Found"), {"Location": str(location)}, ""
  56. def static_file_location(context, path):
  57. """
  58. Given an "intermediate" URL, return the path to that file on disk.
  59. Used by `serve_static_file`.
  60. The given path is in CAT-SOOP's internal format, a list of strings. The
  61. first string represents the course in question, or one of the following:
  62. * `_base` to look in the CAT-SOOP source directory
  63. * `_qtype` to look in a question type's directory
  64. * `_handler` to look in a handler's directory
  65. * `_plugin` to look in a plugin's directory
  66. Regardless of whether the first element is a course or one of the special
  67. values above, the function proceeds by working its way down the given
  68. directories (all elements but the last in the given list). Upon arriving
  69. in that directory, it looks for a directory called `__STATIC__` containing
  70. a file with the given name.
  71. **Parameters:**
  72. * `context`: the context associated with this request
  73. * `path`: a list of directory names, starting with the course
  74. **Returns:** a string containing the location of the requested file on
  75. disk.
  76. """
  77. loc = ""
  78. rest = path[2:]
  79. if path[0] == "_base":
  80. # serving from base
  81. loc = os.path.join(
  82. context.get("cs_fs_root", base_context.cs_fs_root), "__STATIC__"
  83. )
  84. rest = path[1:]
  85. elif path[0] == "_plugin":
  86. # serving from plugin
  87. loc = os.path.join(
  88. context.get("cs_data_root", base_context.cs_data_root),
  89. "plugins",
  90. path[1],
  91. "__STATIC__",
  92. )
  93. elif path[0] == "_handler":
  94. # serving from handler
  95. loc = os.path.join(
  96. context.get("cs_fs_root", base_context.cs_fs_root),
  97. "__HANDLERS__",
  98. path[1],
  99. "__STATIC__",
  100. )
  101. elif path[0] == "_qtype":
  102. # serving from qtype
  103. loc = os.path.join(
  104. context.get("cs_fs_root", base_context.cs_fs_root),
  105. "__QTYPES__",
  106. path[1],
  107. "__STATIC__",
  108. )
  109. elif path[0] == "_auth":
  110. # serving from qtype
  111. loc = os.path.join(
  112. context.get("cs_fs_root", base_context.cs_fs_root),
  113. "__AUTH__",
  114. path[1],
  115. "__STATIC__",
  116. )
  117. else:
  118. # preprocess the path to prune out 'dots' and 'double-dots'
  119. course = path[0]
  120. path = path[1:]
  121. newpath = []
  122. for ix in range(len(path)):
  123. cur = path[ix]
  124. if cur == ".":
  125. pass
  126. elif cur == "..":
  127. newpath = newpath[:-1]
  128. else:
  129. dname = loader.get_directory_name(context, course, path[:ix], cur)
  130. newpath.append(dname if dname is not None else cur)
  131. # trace up the path to find the lowest point that has a
  132. # __STATIC__ directory
  133. basepath = os.path.join(
  134. context.get("cs_data_root", base_context.cs_data_root), "courses", course
  135. )
  136. for ix in range(len(newpath) - 1, -1, -1):
  137. rest = newpath[ix:]
  138. loc = os.path.join(basepath, *newpath[:ix], "__STATIC__")
  139. if os.path.isdir(loc):
  140. break
  141. rest = [i for i in rest if i not in {"..", "."}]
  142. return os.path.join(loc, *rest)
  143. def content_file_location(context, path):
  144. """
  145. Returns the location (filename on disk) of the content file for the dynamic
  146. page represented by `path`.
  147. This function is responsible for looking for content files (regardless of
  148. their extension), and also for looking for pages that don't have an
  149. associated directory (i.e., pages represented by a single file).
  150. **Parameters:**
  151. * `context`: the context associated with this request
  152. * `path` is an "intermediate" URL (i.e., a list of strings starting with a
  153. course).
  154. **Returns:** a string containing the location of the requested file on
  155. disk.
  156. """
  157. course = path[0]
  158. path = path[1:]
  159. newpath = []
  160. broke = False
  161. cur = ""
  162. for ix in range(len(path)):
  163. cur = path[ix]
  164. if cur == ".":
  165. pass
  166. elif cur == "..":
  167. newpath = newpath[:-1]
  168. else:
  169. try:
  170. dname = loader.get_directory_name(context, course, path[:ix], cur)
  171. except FileNotFoundError:
  172. return None
  173. if dname is None:
  174. if ix != len(path) - 1:
  175. return None
  176. broke = True
  177. break
  178. newpath.append(dname if dname != "" else cur)
  179. basepath = loader.get_course_fs_location(context, course)
  180. basepath = os.path.join(basepath, *newpath)
  181. for f in language.source_formats:
  182. if broke and (course == "_util" or not course.startswith("_")):
  183. fn = os.path.join(basepath, "%s.%s" % (cur, f))
  184. if os.path.isfile(fn) and not (cur.startswith(".") or cur.startswith("_")):
  185. return fn
  186. else:
  187. # then for directories with content files
  188. fname = os.path.join(basepath, "content.%s" % f)
  189. if os.path.isfile(fname):
  190. return fname
  191. return None
  192. def serve_static_file(context, fname, environment=None, stream=False, streamchunk=4096):
  193. """
  194. Generate an HTTP response to serve up a static file, or a 404 error if the
  195. file does not exist. Makes use of the browser's cache when possible.
  196. **Parameters**:
  197. * `context`: the context associated with this request
  198. * `fname`: the location on disk of the file to be sent
  199. **Optional Parameters:**
  200. * `environment` (default `{}`): the environment variables associated with
  201. the request
  202. * `stream` (default `False`): whether this file should be streamed (instead
  203. of sent as one bytestring). Regardless of the value of `stream`, files
  204. above 1MB are always streamed.
  205. * `streamchunk` (default `4096`): the size, in bytes, of the chunks in the
  206. resulting stream
  207. """
  208. environment = environment or {}
  209. try:
  210. status = ("200", "OK")
  211. headers = {"Content-type": mimetypes.guess_type(fname)[0] or "text/plain"}
  212. mtime = os.stat(fname).st_mtime
  213. headers["ETag"] = str(hash(mtime))
  214. headers["Cache-Control"] = "no-cache"
  215. if (
  216. "HTTP_IF_NONE_MATCH" in environment
  217. and headers["ETag"] == environment["HTTP_IF_NONE_MATCH"]
  218. ):
  219. return ("304", "Not Modified"), headers, ""
  220. headers["Content-length"] = os.path.getsize(fname)
  221. f = open(fname, "rb")
  222. if stream or headers["Content-length"] > 1024 * 1024:
  223. def streamer():
  224. r = f.read(streamchunk)
  225. while len(r) > 0:
  226. yield r
  227. r = f.read(streamchunk)
  228. f.close()
  229. out = streamer()
  230. else:
  231. out = f.read()
  232. f.close()
  233. headers["Content-length"] = str(headers["Content-length"])
  234. except:
  235. status, headers, out = errors.do_404_message(context)
  236. return status, headers, out
  237. def is_static(context, path):
  238. """
  239. **Parameters**:
  240. * `context`: the context associated with this request
  241. * `path`: an "intermediate" URL (list of strings) representing a given
  242. resource
  243. **Returns: `True` if the path represents a static file, and `False`
  244. otherwise
  245. """
  246. return os.path.isfile(static_file_location(context, path))
  247. def is_resource(context, path):
  248. """
  249. **Parameters:**
  250. * `context`: the context associated with this request
  251. * `path`: an "intermediate" URL (list of strings) representing a given
  252. resource
  253. **Returns:** `True` if the path represents a dynamic page with a content
  254. file, and `False` otherwise.
  255. """
  256. return content_file_location(context, path) is not None
  257. def _real_url_helper(context, url):
  258. u = [context.get("cs_url_root", base_context.cs_url_root), "_static"]
  259. u2 = u[:1]
  260. end = url.split("/")[1:]
  261. if url.startswith("BASE"):
  262. new = ["_base"]
  263. pre = u + new if is_static(context, new + end) else u2
  264. elif url.startswith("COURSE"):
  265. new = [str(context["cs_course"])]
  266. floc = content_file_location(context, new + end)
  267. if floc is not None and os.path.isfile(floc):
  268. pre = u2 + new
  269. else:
  270. pre = u + new
  271. elif url.startswith("CURRENT"):
  272. new = context["cs_path_info"]
  273. test_floc = content_file_location(context, new)
  274. _, test_file = os.path.split(test_floc)
  275. if test_file.rsplit(".", 1)[0] != "content":
  276. new = new[:-1]
  277. floc = content_file_location(context, new + end)
  278. if floc is not None and os.path.isfile(floc):
  279. pre = u2 + new
  280. else:
  281. pre = u + new
  282. elif (
  283. url.startswith("_handler")
  284. or url.startswith("_qtype")
  285. or url.startswith("_plugin")
  286. or url.startswith("_auth")
  287. ):
  288. pre = u
  289. end = [url]
  290. else:
  291. pre = [url]
  292. end = []
  293. return pre + end
  294. def get_real_url(context, url):
  295. """
  296. Convert a location from our internal representation to something that will
  297. actually point the web browser to the right place.
  298. Links in CAT-SOOP can begin with `BASE`, `COURSE`, `CURRENT`, `_qtype`,
  299. `_handler`, `_auth`, or `_plugin`. This function takes in a URL
  300. in that form and returns the corresponding URL.
  301. **Parameters:**
  302. * `context`: the context associated with this request
  303. * `url`: a location, possibly starting with a "magic" location (e.g.,
  304. `CURRENT`)
  305. **Returns:** a string containing a URL pointing to the corresponding
  306. resource
  307. """
  308. u = urllib.parse.urlparse(url)
  309. original = urllib.parse.urlunparse(u[:3] + ("", "", ""))
  310. end = ""
  311. if original.endswith("/"):
  312. original = original[:-1]
  313. end = "/"
  314. new_url = "/".join(_real_url_helper(context, original)) + end
  315. u = ("", "", new_url) + u[3:]
  316. return urllib.parse.urlunparse(u)
  317. def dict_from_cgi_form(cgi_form):
  318. """
  319. Convert CGI FieldStorage into a dictionary
  320. """
  321. o = {}
  322. for key in cgi_form:
  323. res = cgi_form[key]
  324. try:
  325. if res.file:
  326. o[key] = res.file.read()
  327. else:
  328. o[key] = res.value
  329. except:
  330. o[key] = res
  331. return o
  332. def display_page(context):
  333. """
  334. Generate the HTTP response for a dynamically-generated page.
  335. **Parameters:**
  336. * `context`: the context associated with this request
  337. **Returns:** a 3-tuple `(response_code, headers, content)` as expected by
  338. `catsoop.wsgi.application`
  339. """
  340. headers = {"Content-type": "text/html"}
  341. if context.get("cs_user_info", {}).get("real_user", None) is not None:
  342. impmsg = (
  343. '<center><b><font color="red">'
  344. "You are viewing this page as <i>%(u)s</i>.<br/>"
  345. "Actions you take may affect <i>%(u)s</i>'s account."
  346. "</font></b></center><p>"
  347. ) % {"u": context["cs_username"]}
  348. context["cs_content"] = impmsg + context["cs_content"]
  349. context["cs_content"] = language.handle_custom_tags(context, context["cs_content"])
  350. default = os.path.join(
  351. context.get("cs_fs_root", base_context.cs_fs_root),
  352. "__STATIC__",
  353. "templates",
  354. "main.template",
  355. )
  356. temp = _real_url_helper(context, context["cs_template"])
  357. if "_static" in temp:
  358. default = static_file_location(context, temp[2:])
  359. loader.run_plugins(context, context["cs_course"], "post_render", context)
  360. f = open(default)
  361. template = f.read()
  362. f.close()
  363. out = (
  364. language.handle_custom_tags(context, CSFormatter().format(template, **context))
  365. + "\n"
  366. )
  367. headers.update(context.get("cs_additional_headers", {}))
  368. headers.update({"Last-Modified": formatdate()})
  369. return ("200", "OK"), headers, out
  370. def _breadcrumbs_html(context):
  371. _defined = context.get("cs_breadcrumbs_html", None)
  372. if callable(_defined):
  373. return _defined(context)
  374. elif _defined is not None:
  375. return _defined
  376. if context.get("cs_course", None) in {
  377. None,
  378. "_util",
  379. "_qtype",
  380. "_handler",
  381. "_plugin",
  382. "_auth",
  383. "_api",
  384. }:
  385. return ""
  386. if len(context.get("cs_loader_states", [])) < 2:
  387. return ""
  388. elements = []
  389. to_skip = context.get("cs_breadcrumbs_skip_paths", [])
  390. link = "BASE"
  391. for ix, elt in enumerate(context["cs_loader_states"]):
  392. link = link + "/" + context["cs_path_info"][ix]
  393. if "/".join(context["cs_path_info"][1 : ix + 1]) in to_skip:
  394. continue
  395. if context.get("cs_breadcrumbs_skip", False):
  396. continue
  397. name = (
  398. elt.get("cs_long_name", context["cs_path_info"][ix]) if ix != 0 else "Home"
  399. )
  400. name = language.source_transform_string(context, name)
  401. elements.append('<span class="line"><a href="%s">%s</a></span>' % (link, name))
  402. return ' <span class="cs_nav_separator">/</span> '.join(elements)
  403. def md5(x):
  404. return hashlib.md5(x.encode()).hexdigest()
  405. def _top_menu_html(topmenu, header=True):
  406. if isinstance(topmenu, str):
  407. return topmenu
  408. else:
  409. out = ""
  410. for i in topmenu:
  411. if i == "divider":
  412. out += '\n<div class="divider"></div>'
  413. continue
  414. link = i["link"]
  415. if isinstance(link, str):
  416. out += '\n<a href="%s" class="cs_top_menu_item">%s</a>' % (link, i["text"])
  417. else:
  418. menu_id = md5(str(i))
  419. out += '\n<div class="dropdown" onmouseleave="clearMenu(this);">'
  420. out += (
  421. '\n<label class="dropbtn cs_top_menu_item" for="cs_menu_%s">%s<span class="downarrow">▼</span></label>'
  422. % (menu_id, i["text"])
  423. )
  424. out += (
  425. '\n<input type="checkbox" class="dropdown-checkbox" id="cs_menu_%s" checked="false"/>'
  426. % menu_id
  427. )
  428. out += '\n<div class="dropdown-content">'
  429. out += _top_menu_html(link, False)
  430. out += "</div>"
  431. out += "</div>"
  432. if header:
  433. return (
  434. out
  435. + '<a href="javascript:void(0);" class="icon" onclick="toggleResponsiveHeader()">&#9776;</a>'
  436. )
  437. return out
  438. def _set_colors(context):
  439. if context["cs_light_color"] is None:
  440. context["cs_light_color"] = _compute_light_color(context["cs_base_color"])
  441. if context.get("cs_base_font_color", None) is None:
  442. context["cs_base_font_color"] = _font_color_from_background(
  443. context["cs_base_color"]
  444. )
  445. if context.get("cs_light_font_color", None) is None:
  446. context["cs_light_font_color"] = _font_color_from_background(
  447. context["cs_light_color"]
  448. )
  449. def _hex_to_rgb(x):
  450. if x.startswith("#"):
  451. return _hex_to_rgb(x[1:])
  452. if len(x) == 3:
  453. return _hex_to_rgb("".join(i * 2 for i in x))
  454. try:
  455. return tuple(int(x[i * 2 : i * 2 + 2], 16) for i in range(3))
  456. except:
  457. return (0, 0, 0)
  458. def _luminance(rgb_tuple):
  459. r, g, b = rgb_tuple
  460. return (0.299 * r + 0.587 * g + 0.114 * b) / 255
  461. def _font_color_from_background(bg):
  462. return "#000" if _luminance(_hex_to_rgb(bg)) >= 0.5 else "#fff"
  463. def _hex(n):
  464. n = int(n)
  465. return hex(n)[2:4]
  466. def _rgb_to_hex(tup):
  467. return "#%s%s%s" % tuple(map(_hex, tup))
  468. def _rgb_to_hsv(tup):
  469. return colorsys.rgb_to_hsv(*(i / 255 for i in tup))
  470. def _hsv_to_rgb(tup):
  471. return tuple(int(i * 255) for i in colorsys.hsv_to_rgb(*tup))
  472. def _clip(x, lo=0, hi=1):
  473. return min(hi, max(x, lo))
  474. def _compute_light_color(base):
  475. base_hsv = _rgb_to_hsv(_hex_to_rgb(base))
  476. light_hsv = (base_hsv[0], _clip(base_hsv[1] - 0.2), _clip(base_hsv[2] + 0.2))
  477. return _rgb_to_hex(_hsv_to_rgb(light_hsv))
  478. def get_client_ipaddr(environment):
  479. try:
  480. return environment["HTTP_X_FORWARDED_FOR"].split(",")[-1].strip()
  481. except KeyError:
  482. return environment["REMOTE_ADDR"]
  483. def main(environment, return_context=False, form_data=None):
  484. """
  485. Generate the page content associated with this request, properly handling
  486. dynamic pages and static files.
  487. This function is the main entrypoint into CAT-SOOP. It is responsible for:
  488. * gathering form data
  489. * organizing execution of `preload.py` files
  490. * authenticating users
  491. * loading page source and dispatching to the proper handlers
  492. * displaying the result
  493. * handling errors in these steps
  494. **Parameters:**
  495. * `environment`: a dictionary containing the environment variables
  496. associated with this request.
  497. * `return_context`: (bool) set to True if the context dict should be returned
  498. (instead of the usual tuple) on success -- used for unit tests
  499. * `form_data`: (dict) provided by LTI on callback, because form information is
  500. removed from environment after initial call
  501. **Returns:** a 3-tuple `(response_code, headers, content)` as expected by
  502. `catsoop.wsgi.application`
  503. """
  504. context = {}
  505. context["cs_env"] = environment
  506. context["cs_now"] = time.now()
  507. force_error = False
  508. try:
  509. # DETERMINE WHAT PAGE WE ARE LOADING
  510. path_info = environment.get("PATH_INFO", "/")
  511. context["cs_original_path"] = path_info[1:]
  512. path_info = [i for i in path_info.split("/") if i != ""]
  513. if path_info and not path_info[0] == "_static":
  514. LOGGER.info("[dispatch.main] path_info=%s" % path_info)
  515. # RETURN STATIC FILE RESPONSE RIGHT AWAY
  516. if len(path_info) > 0 and path_info[0] == "_static":
  517. return serve_static_file(
  518. context, static_file_location(context, path_info[1:]), environment
  519. )
  520. # LOAD FORM DATA
  521. if "wsgi.input" in environment:
  522. # need to read post variables from wsgi.input
  523. fields = cgi.FieldStorage(
  524. fp=environment["wsgi.input"],
  525. environ=environment,
  526. keep_blank_values=True,
  527. )
  528. else:
  529. fields = cgi.FieldStorage()
  530. form_data = form_data or dict_from_cgi_form(fields)
  531. LOGGER.error("[dispatch] form_data=%s" % str(form_data)[:400])
  532. # INITIALIZE CONTEXT
  533. context["cs_additional_headers"] = {}
  534. context["cs_path_info"] = path_info
  535. context["cs_form"] = form_data
  536. qstring = urllib.parse.parse_qs(environment.get("QUERY_STRING", ""))
  537. context["cs_qstring"] = qstring
  538. # LOAD GLOBAL DATA
  539. e = loader.load_global_data(context)
  540. if len(path_info) > 0:
  541. context["cs_short_name"] = path_info[-1]
  542. context["cs_course"] = path_info[0]
  543. path_info = path_info[1:]
  544. # SET SOME CONSTANTS FOR THE TEMPLATE (may be changed later)
  545. course = context.get("cs_course", None)
  546. if course is None or course in {
  547. "_util",
  548. "_qtype",
  549. "_handler",
  550. "_plugin",
  551. "_auth",
  552. }:
  553. context["cs_home_link"] = "BASE"
  554. else:
  555. context["cs_home_link"] = "COURSE"
  556. context["cs_top_menu_html"] = ""
  557. # CHECK FOR VALID CONFIGURATION
  558. if e is not None:
  559. LOGGER.error("[dispatch.main] internal server error %s" % e)
  560. return (
  561. ("500", "Internal Server Error"),
  562. {"Content-type": "text/plain", "Content-length": str(len(e))},
  563. e,
  564. )
  565. if len(context["_cs_config_errors"]) > 0:
  566. m = (
  567. "The following errors occurred while "
  568. "loading global configuration:\n\n"
  569. )
  570. m += "\n".join(context["_cs_config_errors"])
  571. out = errors.do_error_message(context, m)
  572. force_error = True
  573. LOGGER.error(
  574. "[dispatch.main] global configuration loading error %s"
  575. % context["_cs_config_errors"]
  576. )
  577. raise Exception
  578. # LOAD SESSION DATA (if any)
  579. new = True
  580. context["cs_sid"] = environment.get(
  581. "session_id"
  582. ) # for LTI calling dispatch.main
  583. if context["cs_sid"]:
  584. LOGGER.info(
  585. "[dispatch.main] re-using existing session ID=%s" % context["cs_sid"]
  586. )
  587. else:
  588. context["cs_sid"], new = session.get_session_id(environment)
  589. if new:
  590. hdr = context["cs_additional_headers"]
  591. url_root = urllib.parse.urlparse(context["cs_url_root"])
  592. domain = url_root.netloc.rsplit(":", 1)[0]
  593. path = url_root.path or "/"
  594. hdr["Set-Cookie"] = "catsoop_sid_%s=%s; Domain=%s; Path=%s" % (
  595. util.catsoop_loc_hash(),
  596. context["cs_sid"],
  597. domain,
  598. path,
  599. )
  600. session_data = session.get_session_data(context, context["cs_sid"])
  601. try:
  602. session_data["ip_addr"] = get_client_ipaddr(environment)
  603. except Exception as err:
  604. LOGGER.error(
  605. "[dispatch.main] Cannot get IP address of client, err=%s" % str(err)
  606. )
  607. context["cs_session_data"] = session_data
  608. LOGGER.info(
  609. "[dispatch.main] (%s) session_id=%s"
  610. % (session_data.get("ip_addr"), context["cs_sid"])
  611. )
  612. LOGGER.info("[dispatch.main] path_info=%s" % path_info)
  613. # Handle LTI (must be done prior to authentication & other processing)
  614. if path_info and context["cs_course"] == "_lti":
  615. LOGGER.info("[dispatch.main] serving LTI")
  616. return lti.serve_lti(
  617. context, path_info, environment, form_data, main, return_context
  618. )
  619. # DO PRELOAD FOR THIS REQUEST
  620. if context["cs_course"] is not None:
  621. cfile = content_file_location(context, [context["cs_course"]] + path_info)
  622. LOGGER.info("[dispatch.main] loading content file for course %s" % cfile)
  623. x = loader.do_preload(
  624. context, context["cs_course"], path_info, context, cfile
  625. )
  626. if x == "missing":
  627. LOGGER.info("[dispatch.main] preload returned missing")
  628. return errors.do_404_message(context)
  629. _set_colors(context)
  630. # AUTHENTICATE
  631. # doesn't happen until now because what we want to do might depend
  632. # on what is in the preload.py files, unfortunately
  633. if context.get("cs_auth_required", True):
  634. user_info = auth.get_logged_in_user(context)
  635. LOGGER.info("[dispatch.main] user_info=%s" % user_info)
  636. context["cs_user_info"] = user_info
  637. context["cs_username"] = str(user_info.get("username", None))
  638. if user_info.get("cs_render_now", False):
  639. session.set_session_data(
  640. context, context["cs_sid"], context["cs_session_data"]
  641. )
  642. return display_page(context)
  643. redir = None
  644. if user_info.get("cs_reload", False):
  645. redir = "/".join(
  646. [context.get("cs_url_root", base_context.cs_url_root)]
  647. + context["cs_path_info"]
  648. )
  649. if session_data.get("cs_query_string", ""):
  650. redir += "?" + session_data["cs_query_string"]
  651. if redir is None:
  652. redir = user_info.get("cs_redirect", None)
  653. if redir is not None:
  654. session.set_session_data(
  655. context, context["cs_sid"], context["cs_session_data"]
  656. )
  657. return redirect(redir)
  658. # ONCE WE HAVE THAT, GET USER INFORMATION
  659. context["cs_user_info"] = auth.get_user_information(context)
  660. else:
  661. context["cs_user_info"] = {}
  662. context["cs_username"] = None
  663. # now with user information, update top menu if we can
  664. menu = context.get("cs_top_menu", None)
  665. if isinstance(menu, list) and context.get("cs_auth_required", True):
  666. uname = context["cs_username"]
  667. base_url = "/".join([context["cs_url_root"]] + context["cs_path_info"])
  668. if str(uname) == "None":
  669. menu.append(
  670. {"text": "Log In", "link": "%s?loginaction=login" % base_url}
  671. )
  672. else:
  673. menu_entry = {"text": uname, "link": []}
  674. auth_method = auth.get_auth_type(context)
  675. for i in auth_method.get("user_menu_options", lambda c: [])(
  676. context
  677. ):
  678. menu_entry["link"].append(i)
  679. menu_entry["link"].append(
  680. {"text": "Log Out", "link": "%s?loginaction=logout" % base_url}
  681. )
  682. menu.append(menu_entry)
  683. # MAKE SURE CONTENT FILE EXISTS; 404 IF NOT
  684. if context.get("cs_course", None):
  685. result = is_resource(context, [context["cs_course"]] + path_info)
  686. if not result:
  687. return errors.do_404_message(context)
  688. # FINALLY, LOAD CONTENT
  689. LOGGER.info("[dispatch.main] loading content with path_info=%s" % path_info)
  690. loader.load_content(
  691. context, context["cs_course"], path_info, context, cfile
  692. )
  693. else:
  694. default_course = context.get("cs_default_course", None)
  695. LOGGER.info(
  696. "[dispatch.main] no course specified, using default course %s"
  697. % default_course
  698. )
  699. if default_course is not None:
  700. return redirect(
  701. "/".join(
  702. [
  703. context.get("cs_url_root", base_context.cs_url_root),
  704. default_course,
  705. ]
  706. )
  707. )
  708. else:
  709. _set_colors(context)
  710. root = context.get("cs_fs_root", base_context.cs_fs_root)
  711. path = os.path.join(root, "__STATIC__", "mainpage.catsoop")
  712. with open(path) as f:
  713. context["cs_content"] = f.read()
  714. context["cs_content"] = language.handle_includes(
  715. context, context["cs_content"]
  716. )
  717. context["cs_content"] = language.handle_python_tags(
  718. context, context["cs_content"]
  719. )
  720. context["csm_language"].md_pre_handle(context)
  721. context["cs_handler"] = "passthrough"
  722. # IF NOT DOING A LOG IN ACTION, STORE QUERY STRING
  723. if (
  724. "loginaction" not in context["cs_env"].get("QUERY_STRING", "")
  725. and context.get("cs_handler", "default") == "default"
  726. ):
  727. context["cs_session_data"]["cs_query_string"] = context["cs_env"].get(
  728. "QUERY_STRING", ""
  729. )
  730. LOGGER.info(
  731. "[dispatch.main] handing request using tutor.handle_page, cs_handler=%s"
  732. % context.get("cs_handler")
  733. )
  734. res = tutor.handle_page(context)
  735. if res is not None:
  736. # if we're here, the handler wants to give a specific HTTP response
  737. # (maybe a redirect?)
  738. session.set_session_data(
  739. context, context["cs_sid"], context["cs_session_data"]
  740. )
  741. return res
  742. if "cs_post_handle" in context:
  743. context["cs_post_handle"](context)
  744. loader.run_plugins(context, context["cs_course"], "post_handle", context)
  745. # SET SOME MORE TEMPLATE-SPECIFIC CONSTANTS, AND RENDER THE PAGE
  746. context["cs_top_menu_html"] = _top_menu_html(context["cs_top_menu"], True)
  747. context["cs_breadcrumbs_html"] = _breadcrumbs_html(context)
  748. out = display_page(context) # tweak and display HTML
  749. session_data = context["cs_session_data"]
  750. session.set_session_data(context, context["cs_sid"], session_data)
  751. except Exception as err:
  752. LOGGER.error("[dispatch.main] error occurred: %s" % str(err))
  753. LOGGER.error("[dispatch.main] traceback: %s" % traceback.format_exc())
  754. if not force_error:
  755. out = errors.do_error_message(context)
  756. out = out[:-1] + (out[-1].encode("utf-8"),)
  757. out[1].update({"Content-length": str(len(out[-1]))})
  758. if return_context:
  759. LOGGER.info("[dispatch.main] Returning context instead of HTML response")
  760. return context
  761. return out