routes.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702
  1. import commonmark
  2. import json
  3. import os
  4. import dokk.sparql as sparql
  5. import urllib.parse
  6. from bottle import abort, get, post, redirect, request, response, route, static_file
  7. from datetime import datetime, timezone
  8. from dokk import article_template, graph, graph_public, settings, template, url, user
  9. from string import Template
  10. class requires_signin(object):
  11. """
  12. This is a decorator used for controller to make sure a user is signed in
  13. before entering the controller.
  14. """
  15. def __init__(self, controller):
  16. self.controller = controller
  17. def __call__(self, *args, **kargs):
  18. the_user = user.get_from_session()
  19. if not the_user:
  20. return redirect(url('signin') + '?next=' + urllib.parse.quote(request.url, safe=''))
  21. else:
  22. return self.controller(*args, **kargs)
  23. class requires_signout(object):
  24. """
  25. This is a decorator used for controller to make sure a user is signed out
  26. before entering the controller.
  27. """
  28. def __init__(self, controller):
  29. self.controller = controller
  30. def __call__(self, *args, **kargs):
  31. the_user = user.get_from_session()
  32. if not the_user:
  33. return self.controller(*args, **kargs)
  34. else:
  35. return redirect(url('user', username=the_user['username']))
  36. #### HACK: This is used to retrieve documentation, but it should not stay here!
  37. #### The only purpose of these routes is to maintain compatibility with
  38. #### the old website until I find a better solution.
  39. ###############################################################################
  40. @get('/documentation', name='documentation')
  41. def docs():
  42. """List of documentations and versions."""
  43. # Some page attributes
  44. page = { 'title': 'Documentation' }
  45. # Get list of available docs
  46. docs = sparql.query_public("""
  47. PREFIX : <dokk:/>
  48. PREFIX dokk: <https://ontology.dokk.org/>
  49. PREFIX schema: <http://schema.org/>
  50. DESCRIBE *
  51. WHERE
  52. {
  53. ?docs a dokk:Documentation .
  54. }
  55. """)
  56. docs = docs['@graph']
  57. return template('documentation/index.html', dokk=page, docs=docs)
  58. @get('/documentation/<id>', name='documentationproject')
  59. def documentationproject(id):
  60. """List of documentations and versions."""
  61. # Some page attributes
  62. page = { 'title': '' }
  63. doc = sparql.query_public(Template ("""
  64. PREFIX : <dokk:>
  65. PREFIX dokk: <https://ontology.dokk.org/>
  66. PREFIX schema: <http://schema.org/>
  67. DESCRIBE *
  68. WHERE
  69. {
  70. ?doc a dokk:Documentation ;
  71. dokk:id "$id" .
  72. }
  73. """).substitute({
  74. 'id': id
  75. }))
  76. page['title'] = doc['name'] + ' documentation'
  77. # doc = doc['@graph']
  78. return template('documentation/project.html', dokk=page, doc=doc)
  79. @get('/documentation/<docname>/<release>/', name='documentationdocs')
  80. @get('/documentation/<docname>/<release>/<path:path>/')
  81. def docs_index(docname, release, path=''):
  82. location = settings['dokk.archive'] + '/documentation/html/' + docname + '/' + release + '/'
  83. return static_file(path + '/index.html', root=location)
  84. @get('/documentation/static/<file:path>', name='documentationstatic')
  85. def docsstatic(file):
  86. return bottle.static_file(file, root=self.root+'static')
  87. @get('/documentation/<docname>/<release>/<filename:path>')
  88. def docs_static(docname, release, filename):
  89. location = settings['dokk.archive'] + '/documentation/html/' + docname + '/' + release + '/'
  90. return static_file(filename, root=location)
  91. ###############################################################################
  92. @route('/<filename:re:(favicon.ico|robots.txt)>')
  93. def public_assets(filename):
  94. """
  95. Tell Bottle where it can find some special files that are not inside /static
  96. This is needed because favicon.ico, robots.txt, and other files are expected
  97. to be found under the root path, and they should not be misunderstood as
  98. node IDs.
  99. TODO: find a better way to serve these static files.
  100. :param filename: the name of the file
  101. :return:
  102. """
  103. return static_file(filename, root='./')
  104. @get(settings['dokk.reserved_path'] + '/static/<filename:path>', name='static_assets')
  105. def static_assets(filename):
  106. """
  107. Path containing the static files.
  108. :param filename: The name of the static asset.
  109. :return:
  110. """
  111. return static_file(filename, root='./dokk/static')
  112. @get(settings['dokk.reserved_path'] + '/explore', name='explore')
  113. def explore():
  114. """
  115. Explore the DOKK graph.
  116. """
  117. return template('explore.html')
  118. @get(settings['dokk.reserved_path'] + '/explorenodes')
  119. def explore_nodes():
  120. """
  121. The "explore" controller loads the page template, from where a AJAX request
  122. is sent to this controller to retrieve the list of nodes to be displayed.
  123. """
  124. return graph.get_topics()
  125. @get(settings['dokk.reserved_path'] + '/search', name='search')
  126. def search():
  127. """
  128. Search the DOKK.
  129. """
  130. if 'q' not in request.GET:
  131. return redirect(url('index'))
  132. search_term = request.GET.getunicode('q').strip()
  133. if len(search_term) == 0:
  134. return redirect(url('index'))
  135. results = graph.search_articles(search_term, limit=100)['results']['bindings']
  136. return template('search.html', search_term=search_term, results=results)
  137. @get(settings['dokk.reserved_path'] + '/suggest', name='suggest')
  138. def suggest():
  139. """
  140. Search suggestions when typing in the DOKK search bar.
  141. """
  142. if 'q' not in request.GET:
  143. return ''
  144. search_term = request.GET.getunicode('q').strip()
  145. if len(search_term) == 0:
  146. return ''
  147. articles = graph_public.list_topics(search_term)['results']['bindings']
  148. return template('suggest.html', search_term=search_term, articles=articles)
  149. @get(settings['dokk.reserved_path'] + '/editor', name='editor')
  150. @requires_signin
  151. def dokk_editor():
  152. return template('editor/index.html')
  153. @post(settings['dokk.reserved_path'] + '/editor')
  154. @requires_signin
  155. def dokk_editor_new_node():
  156. """
  157. Handle the <form> POSTs from the Editor's homepage to create new
  158. topics/nodes/templates/queries. This controller basically just takes the POST
  159. data and redirects the user to the edit page.
  160. """
  161. if 'type' not in request.forms or \
  162. 'id' not in request.forms:
  163. return redirect(url('editor'))
  164. type = request.forms.getunicode('type')
  165. id = request.forms.getunicode('id')
  166. if type not in [ 'topic', 'template', 'query' ]:
  167. return redirect(url('editor'))
  168. return redirect(url('edit', type=type, id=id))
  169. @get(settings['dokk.reserved_path'] + '/editor/<type:page>/<id:path>', name='edit')
  170. @requires_signin
  171. def edit(type, id):
  172. """
  173. Edit a page.
  174. :param type: the type of information to edit (article, template, ...)
  175. :param id: ID of the node to edit.
  176. """
  177. # Normalize page ID
  178. normalized_id = graph.normalize_name(id)
  179. if normalized_id != id:
  180. return redirect(url('edit', type=type, id=normalized_id))
  181. # Read existing page
  182. page = type + '/' + id
  183. if graph.exists(page):
  184. revision = graph.get_last_revision(page)
  185. content = revision['content']
  186. revision_number = revision['dokk:revision_number']
  187. else:
  188. revision_number = None
  189. if type == 'topic':
  190. content = template('node/topic.ttl', node_id=id)
  191. elif type == 'article':
  192. content = template('node/article.md')
  193. elif type == 'query':
  194. content = template('node/query.sparql')
  195. elif type == 'template':
  196. content = template('node/template.html')
  197. else:
  198. content = ''
  199. return template('editor/edit.html',
  200. view='edit',
  201. type=type, node_id=id, content=content,
  202. revision_number=revision_number)
  203. @post(settings['dokk.reserved_path'] + '/editor/<type:page>/<id:path>')
  204. @requires_signin
  205. def save_edits(type, id):
  206. """
  207. Save user edits for a page.
  208. """
  209. # Retrieve user
  210. author = user.get_from_session()
  211. if not author:
  212. return redirect(url('index'))
  213. # Retrieve <form> data
  214. rev_number = request.forms.getunicode('revision').strip()
  215. content = request.forms.getunicode('content').strip()[:10485760]
  216. edit_summary = request.forms.getunicode('edit_summary').strip()[:1024]
  217. # Ignore completely if there is no edit_summary. This is just a bad request.
  218. if not edit_summary:
  219. return redirect(url('node', id=id))
  220. # The ID for this page
  221. page = type + '/' + id
  222. # Save a new revision for this page
  223. added = graph.add_revision(page, rev_number, author['username'], content, edit_summary)
  224. # If the revision hasn't been added, there was a conflict. Show merge page.
  225. if not added:
  226. current_revision = graph.get_last_revision(page)
  227. return template('editor/merge.html',
  228. view='merge',
  229. type=type, node_id=id, content=content,
  230. current_revision=current_revision,
  231. revision_number=current_revision['dokk:revision_number'],
  232. edit_summary=edit_summary)
  233. # Revision was added successfully!
  234. current_revision = graph.get_last_revision(page)
  235. if type == 'article':
  236. graph.add_article(id)
  237. return redirect(url('node', id=id))
  238. if type == 'file':
  239. graph.add_file(id)
  240. return redirect(url('revision', number=current_revision['dokk:revision_number'], type=type, id=id))
  241. if type == 'query':
  242. graph.add_query(id)
  243. return redirect(url('revision', number=current_revision['dokk:revision_number'], type=type, id=id))
  244. if type == 'template':
  245. graph.add_template(id)
  246. return redirect(url('revision', number=current_revision['dokk:revision_number'], type=type, id=id))
  247. if type == 'topic':
  248. graph.add_topic(id)
  249. return redirect(url('node', id=id))
  250. @get(settings['dokk.reserved_path'] + '/revisions/<type>/<id:path>', name='revisions')
  251. def show_revisions(type, id):
  252. """
  253. Show a list of revisions for one page.
  254. :param type: the type of information to edit (article, template, ...)
  255. :param id: ID of the node to edit.
  256. """
  257. page_name = type + '/' + id
  258. revisions = graph.list_revisions(page_name)['results']['bindings']
  259. return template('editor/revisions.html',
  260. view='revision',
  261. type=type, node_id=id, page_name=page_name,
  262. revisions=revisions)
  263. @get(settings['dokk.reserved_path'] + '/revision/<number:int>/<type>/<id:path>', name='revision')
  264. def show_revision(number, type, id):
  265. """
  266. Show an old revision.
  267. :param number: The revision number.
  268. :param type: the type of information to edit (article, template, ...)
  269. :param id: ID of the node to edit.
  270. """
  271. page_name = type + '/' + id
  272. revision = graph.get_revision(page_name, number)
  273. current_revision = graph.get_last_revision(page_name)
  274. return template('editor/old_revision.html',
  275. view='revision',
  276. type=type, node_id=id, page_name=page_name,
  277. revision=revision, current_revision=current_revision)
  278. @get(settings['dokk.reserved_path'] + '/upload', name='upload')
  279. @requires_signin
  280. def dokk_upload():
  281. return template('editor/upload.html')
  282. @post(settings['dokk.reserved_path'] + '/upload')
  283. @requires_signin
  284. def dokk_upload_handle():
  285. """
  286. Handle uploaded file.
  287. """
  288. # Store errors here (if file cannot be saved for some reasons)
  289. errors = []
  290. # The file being uploaded
  291. blob = request.files.get('blob')
  292. # Retrieve form data
  293. # Note about Bottle: request.forms['key'] or request.forms.get("key") return
  294. # the raw parameter, the byte string. Instead, request.forms.key and
  295. # request.forms.getunicode("key") return the UTF-8 string. Normally we use
  296. # request.forms.getunicode("key") for consistency and also to avoid any doubts,
  297. # but when using <form enctype="multipart/form-data"> the request body is
  298. # already a UTF-8 string and request.forms.getunicode() tries to encode a
  299. # string that is already UTF-8 encoded. So the value that is returned is either
  300. # an empty string or None, therefore we use the "raw" value from the request.
  301. # It's probably a Bottle bug that needs fixing upstream.
  302. file = {
  303. 'name': request.forms['name'],
  304. 'license': request.forms['license'],
  305. 'source': request.forms['source'],
  306. 'description': request.forms['description']
  307. }
  308. # Clear input data
  309. file['name'] = file['name'].strip()
  310. file['name'] = graph.normalize_name(file['name'])
  311. file['name'] = file['name'].replace('/', '-')
  312. file['license'] = file['license'].strip()
  313. file['source'] = file['source'].strip()
  314. file['description'] = file['description'].strip()
  315. # Where to save the file
  316. file['path'] = settings['dokk.archive'] + '/' + file['name']
  317. # File name must have an extension
  318. if '.' not in blob.filename:
  319. errors.append('no_extension')
  320. # Validate file name
  321. if len(file['name']) == 0:
  322. errors.append('name')
  323. # Validate file extension
  324. if os.path.splitext(blob.filename)[1] != os.path.splitext(file['name'])[1]:
  325. errors.append('extension_mismatch')
  326. # Validate license
  327. if len(file['license']) == 0:
  328. errors.append('license')
  329. # Make sure a file with this name does not exist
  330. if os.path.exists(file['path']):
  331. errors.append('path')
  332. if len(errors) > 0:
  333. return template('editor/upload.html', errors=errors, file=file)
  334. # Everything fine! We can save the file
  335. # Retrieve user
  336. author = user.get_from_session()
  337. if not author:
  338. return redirect(url('editor'))
  339. # Store blob to filesystem
  340. blob.save(file['path'])
  341. # Automatically create a new revision of this file's properties
  342. graph.add_revision(
  343. 'file/' + file['name'],
  344. None,
  345. author['username'],
  346. template('node/file.ttl', filename=file['name'],
  347. license=file['license'],
  348. primary_source=file['source'],
  349. description=file['description']),
  350. 'New file upload.')
  351. # Automatically create a new node with type "dokk:File"
  352. graph.add_file(file['name'])
  353. # Redirect to the file edit page
  354. return redirect(url('edit', type='file', id=file['name']))
  355. @get(settings['dokk.reserved_path'] + '/templates', name='templates')
  356. @requires_signin
  357. def templates():
  358. """
  359. Show a list of all templates available on this DOKK instance.
  360. """
  361. templates = graph.list_templates()['results']['bindings']
  362. return template('editor/templates.html', templates=templates)
  363. @get(settings['dokk.reserved_path'] + '/queries', name='queries')
  364. @requires_signin
  365. def queries():
  366. """
  367. Show a list of all queries available on this DOKK instance.
  368. """
  369. queries = graph.list_queries()['results']['bindings']
  370. return template('editor/queries.html', queries=queries)
  371. @get(settings['dokk.reserved_path'] + '/files', name='files')
  372. @requires_signin
  373. def files():
  374. """
  375. Show a list of all uploaded files on this DOKK instance.
  376. """
  377. all_files = [ f for f in os.listdir(settings['dokk.archive'])
  378. if os.path.isfile(os.path.join(settings['dokk.archive'], f))]
  379. return template('editor/files.html', files=all_files)
  380. @get(settings['dokk.reserved_path'] + '/file/<filename>', name='file')
  381. def show_file(filename):
  382. """
  383. Show a file and its properties. To return the raw binary, see "show_blob()"
  384. """
  385. """
  386. path = settings['dokk.archive'] + '/' + filename
  387. if not os.path.isfile(path):
  388. return abort(404)
  389. """
  390. node = graph.get_file(filename)
  391. return template('editor/file.html', filename=filename, node=node)
  392. @get(settings['dokk.reserved_path'] + '/blob/<filename>', name='blob')
  393. def show_blob(filename):
  394. """
  395. Return a raw file from the repository.
  396. """
  397. return static_file(filename, root=settings['dokk.archive'])
  398. @get(settings['dokk.reserved_path'] + '/signin', name='signin')
  399. @requires_signout
  400. def signin():
  401. """
  402. The sign in page.
  403. """
  404. return template('signin.html')
  405. @post(settings['dokk.reserved_path'] + '/signin')
  406. @requires_signout
  407. def signin_check():
  408. """
  409. Check sign in form.
  410. """
  411. username = request.forms.getunicode('username')
  412. password = request.forms.getunicode('password')
  413. remember = 'remember' in request.forms
  414. if not username or not password:
  415. return template('login.html',
  416. flash = 'Bad login!')
  417. # Retrieve user from database
  418. the_user = user.get_from_password(username, password)
  419. # Username/Password not working
  420. if 'username' not in the_user:
  421. return template('signin.html',
  422. flash = 'Bad login!')
  423. # Stop banned users
  424. if user.is_banned(username):
  425. return template('signin.html',
  426. flash = 'Bad login!')
  427. # ... Everything OK?
  428. # Start new browser session
  429. user.start_session(username, remember)
  430. # Redirect to URL if "?next=" is set in the URL
  431. if 'next' in request.GET:
  432. return redirect(request.GET.getunicode('next'))
  433. # otherwise redirect to homepage
  434. return redirect(url('editor'))
  435. @get(settings['dokk.reserved_path'] + '/register', name='register')
  436. @requires_signout
  437. def register():
  438. """
  439. Register new account.
  440. """
  441. return template('register.html')
  442. @post(settings['dokk.reserved_path'] + '/register')
  443. @requires_signout
  444. def register_new_account():
  445. """
  446. Check form for creating new account.
  447. """
  448. username = request.forms.getunicode('username')
  449. password = request.forms.getunicode('password')
  450. # Normalize username
  451. username = username.strip()
  452. if len(username) == 0:
  453. return None
  454. # Check if username already exists.
  455. if user.exists(username):
  456. return template('register.html',
  457. flash='Name already taken, please choose another one.')
  458. # Password too short?
  459. if len(password) < 8:
  460. return template ('register.html',
  461. flash = 'Password too short.')
  462. # Username OK, Password OK: create new user
  463. user.create(username, password)
  464. # Retrieve user (to check if it was created)
  465. new_user = user.get_from_password(username, password)
  466. # Something bad happened...
  467. if 'username' not in new_user:
  468. return template('register.html',
  469. flash = 'An error has occurred, please try again.')
  470. # Start session...
  471. user.start_session(username)
  472. # ... and go to the homepage
  473. return redirect(url('index'))
  474. @get(settings['dokk.reserved_path'] + '/signout', name='signout')
  475. @requires_signin
  476. def signout():
  477. """
  478. Logout user and return to homepage.
  479. """
  480. user.end_session()
  481. redirect(url('index'))
  482. @get(settings['dokk.reserved_path'] + '/user/<username>', name='user')
  483. def show_user(username):
  484. """
  485. Show a user homepage.
  486. """
  487. return template('user/homepage.html')
  488. @route(settings['dokk.reserved_path'])
  489. @route(settings['dokk.reserved_path'] + '/<path:path>')
  490. def reserved_paths(path=None):
  491. """
  492. Forbid users from editing any page starting with the reserved path.
  493. """
  494. return abort(403) # 403 - Forbidden
  495. @get('/<id:path>', name='node')
  496. def graph_node(id):
  497. """
  498. Read a node's article. This controller must be kept at the end,
  499. because it catches all routes that have not been caught by other routes.
  500. """
  501. # Normalize page ID
  502. normalized_id = graph.normalize_name(id)
  503. # Redirect to the correct page
  504. if normalized_id != id:
  505. return redirect(url('node', id=normalized_id))
  506. # This topic (node) doesn't exist in the database
  507. if not graph_public.topic_exists(normalized_id):
  508. response.status = 404 # Not found
  509. return template('article_not_found.html', node_id=id)
  510. # Retrieve topic node
  511. node = graph_public.get_topic(id)
  512. # Sort templates by name
  513. node_types = sparql.expand(node)[0]['@type']
  514. node_types.sort()
  515. # Move dokk:Topic in first position
  516. if 'https://ontology.dokk.org/Topic' in node_types:
  517. node_types.remove('https://ontology.dokk.org/Topic')
  518. node_types.insert(0, 'https://ontology.dokk.org/Topic')
  519. # Concatenate all templates into a single page
  520. page = ''
  521. for type_id in node_types:
  522. page += "{% include '" + type_id + "' %}\n"
  523. # Render templates for node types
  524. page = article_template(page + '\n', node=node)
  525. # Page variables
  526. dokk = { 'title': node['title'] if 'title' in node else node['id'].replace('_', ' ') }
  527. return template('article.html', dokk=dokk, node=node, page=page)
  528. @get('/', name='index')
  529. def index():
  530. """
  531. Homepage.
  532. """
  533. return template('homepage.html')