Newer
Older
# @author: Gunnar Schaefer, Kevin S. Hahn
import logging
import os
import re
import hashlib
import tarfile
import markdown
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):
def head(self):
"""Return 200 OK."""
pass
def post(self):
log.error(self.request.body)
def get(self):
"""Return API documentation"""
resources = """
Resource | Description
:--------------------------------------------------------|:-----------------------
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
api/login | user login
[(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/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>*
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
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 not self.uid or self.request.get('all').lower() in ('1', 'true'):
sites = list(self.app.db.remotes.find(None, ['name']))
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]
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
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',
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
'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'],
},
{
'title': 'Echo Time',
'type': 'number',
'field': 'acquisition.echo_time',
},
{
'title': 'Size',
'type': 'array',
'items': [
{
'title': 'X',
'type': 'integer',
},
{
'title': 'Y',
'type': 'integer',
},
],
},
],
},
{
'title': 'EEG',
'type': 'array',
'items': [
{
'title': 'Electrode Count',
'type': 'integer',
'field': 'acquisition.electrode_count',
},
],
},
],
}
def search(self):
"""Search."""
return self.search_schema
else:
pass