# @author: Gunnar Schaefer, Kevin S. Hahn import logging log = logging.getLogger('scitran.api') import os import re import hashlib import tarfile import markdown import jsonschema import base import util import tempdir as tempfile DOWNLOAD_SCHEMA = { '$schema': 'http://json-schema.org/draft-04/schema#', 'title': 'Download', 'type': 'object', 'properties': { 'optional': { 'type': 'boolean', }, 'nodes': { 'type': 'array', 'minItems': 1, 'items': { 'type': 'object', 'properties': { 'level': { 'type': 'string', 'enum': ['project', 'session', 'acquisition'], }, '_id': { 'type': 'string', 'pattern': '^[0-9a-f]{24}$', }, }, 'required': ['level', '_id'], 'additionalProperties': False }, }, }, 'required': ['optional', 'nodes'], 'additionalProperties': False } class Core(base.RequestHandler): """/api """ def head(self): """Return 200 OK.""" pass def post(self): log.error(self.request.body) def get(self): """Return API documentation""" resources = """ Resource | Description :--------------------------------------------------------|:----------------------- [(api/sites)] | local and remote sites api/upload | upload api/download | download [(api/log)] | log messages [(api/search)] | search [(api/users)] | list of users [(api/users/count)] | count of users [(api/users/self)] | user identity [(api/users/roles)] | user roles [(api/users/schema)] | schema for single user api/users/*<uid>* | details for user *<uid>* [(api/groups)] | list of groups [(api/groups/count)] | count of groups [(api/groups/schema)] | schema for single group api/groups/*<gid>* | details for group *<gid>* [(api/projects)] | list of projects [(api/projects/count)] | count of projects [(api/projects/groups)] | groups for projects [(api/projects/schema)] | schema for single project api/projects/*<pid>* | details for project *<pid>* api/projects/*<pid>*/sessions | list sessions for project *<pid>* [(api/sessions/count)] | count of sessions [(api/sessions/schema)] | schema for single session api/sessions/*<sid>* | details for session *<sid>* api/sessions/*<sid>*/move | move session *<sid>* to a different project api/sessions/*<sid>*/acquisitions | list acquisitions for session *<sid>* [(api/acquisitions/count)] | count of acquisitions [(api/acquisitions/schema)] | schema for single acquisition api/acquisitions/*<aid>* | details for acquisition *<aid>* [(api/collections)] | list of collections [(api/collections/count)] | count of collections [(api/collections/schema)] | schema for single collection api/collections/*<cid>* | details for collection *<cid>* api/collections/*<cid>*/sessions | list of sessions for collection *<cid>* api/collections/*<cid>*/acquisitions?session=*<sid>* | list of acquisitions for collection *<cid>*, optionally restricted to session *<sid>* """ if self.debug and self.uid: resources = re.sub(r'\[\((.*)\)\]', r'[\1](\1?user=%s)' % self.uid, resources) else: resources = re.sub(r'\[\((.*)\)\]', r'[\1](\1)', resources) resources = resources.replace('<', '<').replace('>', '>').strip() self.response.headers['Content-Type'] = 'text/html; charset=utf-8' self.response.write('<html>\n') self.response.write('<head>\n') self.response.write('<title>SciTran API</title>\n') self.response.write('<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">\n') self.response.write('<style type="text/css">\n') self.response.write('table {width:0%; border-width:1px; padding: 0;border-collapse: collapse;}\n') self.response.write('table tr {border-top: 1px solid #b8b8b8; background-color: white; margin: 0; padding: 0;}\n') self.response.write('table tr:nth-child(2n) {background-color: #f8f8f8;}\n') self.response.write('table thead tr :last-child {width:100%;}\n') self.response.write('table tr th {font-weight: bold; border: 1px solid #b8b8b8; background-color: #cdcdcd; margin: 0; padding: 6px 13px;}\n') self.response.write('table tr th {font-weight: bold; border: 1px solid #b8b8b8; background-color: #cdcdcd; margin: 0; padding: 6px 13px;}\n') self.response.write('table tr td {border: 1px solid #b8b8b8; margin: 0; padding: 6px 13px;}\n') self.response.write('table tr th :first-child, table tr td :first-child {margin-top: 0;}\n') self.response.write('table tr th :last-child, table tr td :last-child {margin-bottom: 0;}\n') self.response.write('</style>\n') self.response.write('</head>\n') self.response.write('<body style="min-width:900px">\n') if self.debug and not self.request.get('user', None): self.response.write('<form name="username" action="" method="get">\n') self.response.write('Username: <input type="text" name="user">\n') self.response.write('<input type="submit" value="Generate Custom Links">\n') self.response.write('</form>\n') self.response.write(markdown.markdown(resources, ['extra'])) self.response.write('</body>\n') self.response.write('</html>\n') def put(self): """Receive a sortable reaper or user upload.""" if 'Content-MD5' not in self.request.headers: self.abort(400, 'Request must contain a valid "Content-MD5" header.') filename = self.request.get('filename', 'upload') data_path = self.app.config['data_path'] quarantine_path = self.app.config['quarantine_path'] with tempfile.TemporaryDirectory(prefix='.tmp', dir=data_path) as tempdir_path: hash_ = hashlib.sha1() filepath = os.path.join(tempdir_path, filename) with open(filepath, 'wb') as fd: for chunk in iter(lambda: self.request.body_file.read(2**20), ''): hash_.update(chunk) fd.write(chunk) if hash_.hexdigest() != self.request.headers['Content-MD5']: self.abort(400, 'Content-MD5 mismatch.') if not tarfile.is_tarfile(filepath): self.abort(415, 'Only tar files are accepted.') log.info('Received %s [%s] from %s' % (filename, util.hrsize(self.request.content_length), self.request.user_agent)) status, detail = util.insert_file(self.app.db.acquisitions, None, None, filepath, hash_.hexdigest(), data_path, quarantine_path) if status != 200: self.abort(status, detail) def _preflight_archivestream(self, req_spec): # check permissions of everything # allow downloads of permitted items only pass def _archivestream(self, tkt_spec): pass def download(self): ticket_id = self.request.get('ticket') if ticket_id: tkt_spec = self.app.db.downloads.find_one({'_id': ticket_id}) if not tkt_spec: self.abort(404, 'no such ticket') if tkt_spec['type'] == 'single': self.response.app_iter = open(tkt_spec['filepath'], 'rb') else: self.response.app_iter = self._archivestream(tkt_spec) # returns iterator self.response.headers['Content-Type'] = 'application/octet-stream' self.response.headers['Content-Disposition'] = 'attachment; filename=' + str(tkt_spec['filename']) self.response.headers['Content-Length'] = str(tkt_spec['size']) # must be set after setting app_iter else: try: req_spec = self.request.json_body jsonschema.validate(req_spec, DOWNLOAD_SCHEMA) except (ValueError, jsonschema.ValidationError) as e: self.abort(400, str(e)) return self._preflight_archivestream(req_spec) def sites(self): """Return local and remote sites.""" if self.public_request or self.request.get('all').lower() in ('1', 'true'): sites = list(self.app.db.remotes.find(None, ['name'])) else: sites = (self.app.db.users.find_one({'_id': self.uid}, ['remotes']) or {}).get('remotes', []) return sites + [{'_id': self.app.config['site_id'], 'name': self.app.config['site_name'], 'onload': True}] def log(self): """Return logs.""" try: logs = open(self.app.config['log_path']).readlines() except IOError as e: if 'Permission denied' in e: body_template = '${explanation}<br /><br />${detail}<br /><br />${comment}' comment = 'To fix permissions, run the following command: chmod o+r ' + self.app.config['log_path'] self.abort(500, detail=str(e), comment=comment, body_template=body_template) else: # file does not exist self.abort(500, 'log_path variable misconfigured or not set') try: n = int(self.request.get('n', 10000)) except: self.abort(400, 'n must be an integer') return [line.strip() for line in reversed(logs) if re.match('[-:0-9 ]{18} +api:(?!.*[/a-z]*/log )', line)][:n] search_schema = { 'title': 'Search', 'type': 'array', 'items': [ { 'title': 'Session', 'type': 'array', 'items': [ { 'title': 'Date', 'type': 'date', 'field': 'session.date', }, { 'title': 'Subject', 'type': 'array', 'items': [ { 'title': 'Name', 'type': 'array', 'items': [ { 'title': 'First', 'type': 'string', 'field': 'session.subject.firstname', }, { 'title': 'Last', 'type': 'string', 'field': 'session.subject.lastname', }, ], }, { 'title': 'Date of Birth', 'type': 'date', 'field': 'session.subject.dob', }, { 'title': 'Sex', 'type': 'string', 'enum': ['male', 'female'], 'field': 'session.subject.sex', }, ], }, ], }, { 'title': 'MR', 'type': 'array', 'items': [ { 'title': 'Scan Type', 'type': 'string', 'enum': ['anatomical', 'fMRI', 'DTI'], 'field': 'acquisition.type', }, { 'title': 'Echo Time', 'type': 'number', 'field': 'acquisition.echo_time', }, { 'title': 'Size', 'type': 'array', 'items': [ { 'title': 'X', 'type': 'integer', 'field': 'acquisition.size.x', }, { 'title': 'Y', 'type': 'integer', 'field': 'acquisition.size.y', }, ], }, ], }, { 'title': 'EEG', 'type': 'array', 'items': [ { 'title': 'Electrode Count', 'type': 'integer', 'field': 'acquisition.electrode_count', }, ], }, ], } def search(self): """Search.""" if self.request.method == 'GET': return self.search_schema else: pass