##// END OF EJS Templates
API: added basic upload api for the file storage
marcink -
r3437:206694bc default
parent child Browse files
Show More
@@ -1,414 +1,481 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import inspect
22 22 import logging
23 23 import itertools
24 import base64
25
26 from pyramid import compat
24 27
25 28 from rhodecode.api import (
26 29 jsonrpc_method, JSONRPCError, JSONRPCForbidden, find_methods)
27 30
28 31 from rhodecode.api.utils import (
29 32 Optional, OAttr, has_superadmin_permission, get_user_or_error)
30 33 from rhodecode.lib.utils import repo2db_mapper
31 34 from rhodecode.lib import system_info
32 35 from rhodecode.lib import user_sessions
33 36 from rhodecode.lib import exc_tracking
34 37 from rhodecode.lib.ext_json import json
35 38 from rhodecode.lib.utils2 import safe_int
36 39 from rhodecode.model.db import UserIpMap
37 40 from rhodecode.model.scm import ScmModel
38 41 from rhodecode.model.settings import VcsSettingsModel
42 from rhodecode.apps.upload_store import utils
43 from rhodecode.apps.upload_store.exceptions import FileNotAllowedException, \
44 FileOverSizeException
39 45
40 46 log = logging.getLogger(__name__)
41 47
42 48
43 49 @jsonrpc_method()
44 50 def get_server_info(request, apiuser):
45 51 """
46 52 Returns the |RCE| server information.
47 53
48 54 This includes the running version of |RCE| and all installed
49 55 packages. This command takes the following options:
50 56
51 57 :param apiuser: This is filled automatically from the |authtoken|.
52 58 :type apiuser: AuthUser
53 59
54 60 Example output:
55 61
56 62 .. code-block:: bash
57 63
58 64 id : <id_given_in_input>
59 65 result : {
60 66 'modules': [<module name>,...]
61 67 'py_version': <python version>,
62 68 'platform': <platform type>,
63 69 'rhodecode_version': <rhodecode version>
64 70 }
65 71 error : null
66 72 """
67 73
68 74 if not has_superadmin_permission(apiuser):
69 75 raise JSONRPCForbidden()
70 76
71 77 server_info = ScmModel().get_server_info(request.environ)
72 78 # rhodecode-index requires those
73 79
74 80 server_info['index_storage'] = server_info['search']['value']['location']
75 81 server_info['storage'] = server_info['storage']['value']['path']
76 82
77 83 return server_info
78 84
79 85
80 86 @jsonrpc_method()
81 87 def get_repo_store(request, apiuser):
82 88 """
83 89 Returns the |RCE| repository storage information.
84 90
85 91 :param apiuser: This is filled automatically from the |authtoken|.
86 92 :type apiuser: AuthUser
87 93
88 94 Example output:
89 95
90 96 .. code-block:: bash
91 97
92 98 id : <id_given_in_input>
93 99 result : {
94 100 'modules': [<module name>,...]
95 101 'py_version': <python version>,
96 102 'platform': <platform type>,
97 103 'rhodecode_version': <rhodecode version>
98 104 }
99 105 error : null
100 106 """
101 107
102 108 if not has_superadmin_permission(apiuser):
103 109 raise JSONRPCForbidden()
104 110
105 111 path = VcsSettingsModel().get_repos_location()
106 112 return {"path": path}
107 113
108 114
109 115 @jsonrpc_method()
110 116 def get_ip(request, apiuser, userid=Optional(OAttr('apiuser'))):
111 117 """
112 118 Displays the IP Address as seen from the |RCE| server.
113 119
114 120 * This command displays the IP Address, as well as all the defined IP
115 121 addresses for the specified user. If the ``userid`` is not set, the
116 122 data returned is for the user calling the method.
117 123
118 124 This command can only be run using an |authtoken| with admin rights to
119 125 the specified repository.
120 126
121 127 This command takes the following options:
122 128
123 129 :param apiuser: This is filled automatically from |authtoken|.
124 130 :type apiuser: AuthUser
125 131 :param userid: Sets the userid for which associated IP Address data
126 132 is returned.
127 133 :type userid: Optional(str or int)
128 134
129 135 Example output:
130 136
131 137 .. code-block:: bash
132 138
133 139 id : <id_given_in_input>
134 140 result : {
135 141 "server_ip_addr": "<ip_from_clien>",
136 142 "user_ips": [
137 143 {
138 144 "ip_addr": "<ip_with_mask>",
139 145 "ip_range": ["<start_ip>", "<end_ip>"],
140 146 },
141 147 ...
142 148 ]
143 149 }
144 150
145 151 """
146 152 if not has_superadmin_permission(apiuser):
147 153 raise JSONRPCForbidden()
148 154
149 155 userid = Optional.extract(userid, evaluate_locals=locals())
150 156 userid = getattr(userid, 'user_id', userid)
151 157
152 158 user = get_user_or_error(userid)
153 159 ips = UserIpMap.query().filter(UserIpMap.user == user).all()
154 160 return {
155 161 'server_ip_addr': request.rpc_ip_addr,
156 162 'user_ips': ips
157 163 }
158 164
159 165
160 166 @jsonrpc_method()
161 167 def rescan_repos(request, apiuser, remove_obsolete=Optional(False)):
162 168 """
163 169 Triggers a rescan of the specified repositories.
164 170
165 171 * If the ``remove_obsolete`` option is set, it also deletes repositories
166 172 that are found in the database but not on the file system, so called
167 173 "clean zombies".
168 174
169 175 This command can only be run using an |authtoken| with admin rights to
170 176 the specified repository.
171 177
172 178 This command takes the following options:
173 179
174 180 :param apiuser: This is filled automatically from the |authtoken|.
175 181 :type apiuser: AuthUser
176 182 :param remove_obsolete: Deletes repositories from the database that
177 183 are not found on the filesystem.
178 184 :type remove_obsolete: Optional(``True`` | ``False``)
179 185
180 186 Example output:
181 187
182 188 .. code-block:: bash
183 189
184 190 id : <id_given_in_input>
185 191 result : {
186 192 'added': [<added repository name>,...]
187 193 'removed': [<removed repository name>,...]
188 194 }
189 195 error : null
190 196
191 197 Example error output:
192 198
193 199 .. code-block:: bash
194 200
195 201 id : <id_given_in_input>
196 202 result : null
197 203 error : {
198 204 'Error occurred during rescan repositories action'
199 205 }
200 206
201 207 """
202 208 if not has_superadmin_permission(apiuser):
203 209 raise JSONRPCForbidden()
204 210
205 211 try:
206 212 rm_obsolete = Optional.extract(remove_obsolete)
207 213 added, removed = repo2db_mapper(ScmModel().repo_scan(),
208 214 remove_obsolete=rm_obsolete)
209 215 return {'added': added, 'removed': removed}
210 216 except Exception:
211 217 log.exception('Failed to run repo rescann')
212 218 raise JSONRPCError(
213 219 'Error occurred during rescan repositories action'
214 220 )
215 221
216 222
217 223 @jsonrpc_method()
218 224 def cleanup_sessions(request, apiuser, older_then=Optional(60)):
219 225 """
220 226 Triggers a session cleanup action.
221 227
222 228 If the ``older_then`` option is set, only sessions that hasn't been
223 229 accessed in the given number of days will be removed.
224 230
225 231 This command can only be run using an |authtoken| with admin rights to
226 232 the specified repository.
227 233
228 234 This command takes the following options:
229 235
230 236 :param apiuser: This is filled automatically from the |authtoken|.
231 237 :type apiuser: AuthUser
232 238 :param older_then: Deletes session that hasn't been accessed
233 239 in given number of days.
234 240 :type older_then: Optional(int)
235 241
236 242 Example output:
237 243
238 244 .. code-block:: bash
239 245
240 246 id : <id_given_in_input>
241 247 result: {
242 248 "backend": "<type of backend>",
243 249 "sessions_removed": <number_of_removed_sessions>
244 250 }
245 251 error : null
246 252
247 253 Example error output:
248 254
249 255 .. code-block:: bash
250 256
251 257 id : <id_given_in_input>
252 258 result : null
253 259 error : {
254 260 'Error occurred during session cleanup'
255 261 }
256 262
257 263 """
258 264 if not has_superadmin_permission(apiuser):
259 265 raise JSONRPCForbidden()
260 266
261 267 older_then = safe_int(Optional.extract(older_then)) or 60
262 268 older_than_seconds = 60 * 60 * 24 * older_then
263 269
264 270 config = system_info.rhodecode_config().get_value()['value']['config']
265 271 session_model = user_sessions.get_session_handler(
266 272 config.get('beaker.session.type', 'memory'))(config)
267 273
268 274 backend = session_model.SESSION_TYPE
269 275 try:
270 276 cleaned = session_model.clean_sessions(
271 277 older_than_seconds=older_than_seconds)
272 278 return {'sessions_removed': cleaned, 'backend': backend}
273 279 except user_sessions.CleanupCommand as msg:
274 280 return {'cleanup_command': msg.message, 'backend': backend}
275 281 except Exception as e:
276 282 log.exception('Failed session cleanup')
277 283 raise JSONRPCError(
278 284 'Error occurred during session cleanup'
279 285 )
280 286
281 287
282 288 @jsonrpc_method()
283 289 def get_method(request, apiuser, pattern=Optional('*')):
284 290 """
285 291 Returns list of all available API methods. By default match pattern
286 292 os "*" but any other pattern can be specified. eg *comment* will return
287 293 all methods with comment inside them. If just single method is matched
288 294 returned data will also include method specification
289 295
290 296 This command can only be run using an |authtoken| with admin rights to
291 297 the specified repository.
292 298
293 299 This command takes the following options:
294 300
295 301 :param apiuser: This is filled automatically from the |authtoken|.
296 302 :type apiuser: AuthUser
297 303 :param pattern: pattern to match method names against
298 304 :type pattern: Optional("*")
299 305
300 306 Example output:
301 307
302 308 .. code-block:: bash
303 309
304 310 id : <id_given_in_input>
305 311 "result": [
306 312 "changeset_comment",
307 313 "comment_pull_request",
308 314 "comment_commit"
309 315 ]
310 316 error : null
311 317
312 318 .. code-block:: bash
313 319
314 320 id : <id_given_in_input>
315 321 "result": [
316 322 "comment_commit",
317 323 {
318 324 "apiuser": "<RequiredType>",
319 325 "comment_type": "<Optional:u'note'>",
320 326 "commit_id": "<RequiredType>",
321 327 "message": "<RequiredType>",
322 328 "repoid": "<RequiredType>",
323 329 "request": "<RequiredType>",
324 330 "resolves_comment_id": "<Optional:None>",
325 331 "status": "<Optional:None>",
326 332 "userid": "<Optional:<OptionalAttr:apiuser>>"
327 333 }
328 334 ]
329 335 error : null
330 336 """
331 337 if not has_superadmin_permission(apiuser):
332 338 raise JSONRPCForbidden()
333 339
334 340 pattern = Optional.extract(pattern)
335 341
336 342 matches = find_methods(request.registry.jsonrpc_methods, pattern)
337 343
338 344 args_desc = []
339 345 if len(matches) == 1:
340 346 func = matches[matches.keys()[0]]
341 347
342 348 argspec = inspect.getargspec(func)
343 349 arglist = argspec[0]
344 350 defaults = map(repr, argspec[3] or [])
345 351
346 352 default_empty = '<RequiredType>'
347 353
348 354 # kw arguments required by this method
349 355 func_kwargs = dict(itertools.izip_longest(
350 356 reversed(arglist), reversed(defaults), fillvalue=default_empty))
351 357 args_desc.append(func_kwargs)
352 358
353 359 return matches.keys() + args_desc
354 360
355 361
356 362 @jsonrpc_method()
357 363 def store_exception(request, apiuser, exc_data_json, prefix=Optional('rhodecode')):
358 364 """
359 365 Stores sent exception inside the built-in exception tracker in |RCE| server.
360 366
361 367 This command can only be run using an |authtoken| with admin rights to
362 368 the specified repository.
363 369
364 370 This command takes the following options:
365 371
366 372 :param apiuser: This is filled automatically from the |authtoken|.
367 373 :type apiuser: AuthUser
368 374
369 375 :param exc_data_json: JSON data with exception e.g
370 376 {"exc_traceback": "Value `1` is not allowed", "exc_type_name": "ValueError"}
371 377 :type exc_data_json: JSON data
372 378
373 379 :param prefix: prefix for error type, e.g 'rhodecode', 'vcsserver', 'rhodecode-tools'
374 380 :type prefix: Optional("rhodecode")
375 381
376 382 Example output:
377 383
378 384 .. code-block:: bash
379 385
380 386 id : <id_given_in_input>
381 387 "result": {
382 388 "exc_id": 139718459226384,
383 389 "exc_url": "http://localhost:8080/_admin/settings/exceptions/139718459226384"
384 390 }
385 391 error : null
386 392 """
387 393 if not has_superadmin_permission(apiuser):
388 394 raise JSONRPCForbidden()
389 395
390 396 prefix = Optional.extract(prefix)
391 397 exc_id = exc_tracking.generate_id()
392 398
393 399 try:
394 400 exc_data = json.loads(exc_data_json)
395 401 except Exception:
396 402 log.error('Failed to parse JSON: %r', exc_data_json)
397 403 raise JSONRPCError('Failed to parse JSON data from exc_data_json field. '
398 404 'Please make sure it contains a valid JSON.')
399 405
400 406 try:
401 407 exc_traceback = exc_data['exc_traceback']
402 408 exc_type_name = exc_data['exc_type_name']
403 409 except KeyError as err:
404 410 raise JSONRPCError('Missing exc_traceback, or exc_type_name '
405 411 'in exc_data_json field. Missing: {}'.format(err))
406 412
407 413 exc_tracking._store_exception(
408 414 exc_id=exc_id, exc_traceback=exc_traceback,
409 415 exc_type_name=exc_type_name, prefix=prefix)
410 416
411 417 exc_url = request.route_url(
412 418 'admin_settings_exception_tracker_show', exception_id=exc_id)
413 419 return {'exc_id': exc_id, 'exc_url': exc_url}
414 420
421
422 @jsonrpc_method()
423 def upload_file(request, apiuser, filename, content):
424 """
425 Upload API for the file_store
426
427 Example usage from CLI::
428 rhodecode-api --instance-name=enterprise-1 upload_file "{\"content\": \"$(cat image.jpg | base64)\", \"filename\":\"image.jpg\"}"
429
430
431 This command can only be run using an |authtoken| with admin rights to
432 the specified repository.
433
434 This command takes the following options:
435
436 :param apiuser: This is filled automatically from the |authtoken|.
437 :type apiuser: AuthUser
438 :param filename: name of the file uploaded
439 :type filename: str
440 :param content: base64 encoded content of the uploaded file
441 :type prefix: str
442
443 Example output:
444
445 .. code-block:: bash
446
447 id : <id_given_in_input>
448 result: {
449 "access_path": "/_file_store/download/84d156f7-8323-4ad3-9fce-4a8e88e1deaf-0.jpg",
450 "access_path_fqn": "http://server.domain.com/_file_store/download/84d156f7-8323-4ad3-9fce-4a8e88e1deaf-0.jpg",
451 "store_fid": "84d156f7-8323-4ad3-9fce-4a8e88e1deaf-0.jpg"
452 }
453 error : null
454 """
455 if not has_superadmin_permission(apiuser):
456 raise JSONRPCForbidden()
457
458 storage = utils.get_file_storage(request.registry.settings)
459
460 try:
461 file_obj = compat.NativeIO(base64.decodestring(content))
462 except Exception as exc:
463 raise JSONRPCError('File `{}` content decoding error: {}.'.format(filename, exc))
464
465 metadata = {
466 'filename': filename,
467 'size': '', # filled by save_file
468 'user_uploaded': {'username': apiuser.username,
469 'user_id': apiuser.user_id,
470 'ip': apiuser.ip_addr}}
471 try:
472 store_fid = storage.save_file(file_obj, filename, metadata=metadata)
473 except FileNotAllowedException:
474 raise JSONRPCError('File `{}` is not allowed.'.format(filename))
475
476 except FileOverSizeException:
477 raise JSONRPCError('File `{}` is exceeding allowed limit.'.format(filename))
478
479 return {'store_fid': store_fid,
480 'access_path_fqn': request.route_url('download_file', fid=store_fid),
481 'access_path': request.route_path('download_file', fid=store_fid)}
@@ -1,167 +1,171 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import os
22 import time
22 23 import shutil
23 24
24 25 from rhodecode.lib.ext_json import json
25 26 from rhodecode.apps.upload_store import utils
26 27 from rhodecode.apps.upload_store.extensions import resolve_extensions
27 28 from rhodecode.apps.upload_store.exceptions import FileNotAllowedException
28 29
30 METADATA_VER = 'v1'
31
29 32
30 33 class LocalFileStorage(object):
31 34
32 35 @classmethod
33 36 def resolve_name(cls, name, directory):
34 37 """
35 38 Resolves a unique name and the correct path. If a filename
36 39 for that path already exists then a numeric prefix with values > 0 will be
37 40 added, for example test.jpg -> test-1.jpg etc. initially file would have 0 prefix.
38 41
39 42 :param name: base name of file
40 43 :param directory: absolute directory path
41 44 """
42 45
43 46 basename, ext = os.path.splitext(name)
44 47 counter = 0
45 48 while True:
46 49 name = '%s-%d%s' % (basename, counter, ext)
47 50 path = os.path.join(directory, name)
48 51 if not os.path.exists(path):
49 52 return name, path
50 53 counter += 1
51 54
52 55 def __init__(self, base_path, extension_groups=None):
53 56
54 57 """
55 58 Local file storage
56 59
57 60 :param base_path: the absolute base path where uploads are stored
58 61 :param extension_groups: extensions string
59 62 """
60 63
61 64 extension_groups = extension_groups or ['any']
62 65 self.base_path = base_path
63 66 self.extensions = resolve_extensions([], groups=extension_groups)
64 67
65 68 def store_path(self, filename):
66 69 """
67 70 Returns absolute file path of the filename, joined to the
68 71 base_path.
69 72
70 73 :param filename: base name of file
71 74 """
72 75 return os.path.join(self.base_path, filename)
73 76
74 77 def delete(self, filename):
75 78 """
76 79 Deletes the filename. Filename is resolved with the
77 80 absolute path based on base_path. If file does not exist,
78 81 returns **False**, otherwise **True**
79 82
80 83 :param filename: base name of file
81 84 """
82 85 if self.exists(filename):
83 86 os.remove(self.store_path(filename))
84 87 return True
85 88 return False
86 89
87 90 def exists(self, filename):
88 91 """
89 92 Checks if file exists. Resolves filename's absolute
90 93 path based on base_path.
91 94
92 95 :param filename: base name of file
93 96 """
94 97 return os.path.exists(self.store_path(filename))
95 98
96 99 def filename_allowed(self, filename, extensions=None):
97 100 """Checks if a filename has an allowed extension
98 101
99 102 :param filename: base name of file
100 103 :param extensions: iterable of extensions (or self.extensions)
101 104 """
102 105 _, ext = os.path.splitext(filename)
103 106 return self.extension_allowed(ext, extensions)
104 107
105 108 def extension_allowed(self, ext, extensions=None):
106 109 """
107 110 Checks if an extension is permitted. Both e.g. ".jpg" and
108 111 "jpg" can be passed in. Extension lookup is case-insensitive.
109 112
110 113 :param extensions: iterable of extensions (or self.extensions)
111 114 """
112 115
113 116 extensions = extensions or self.extensions
114 117 if not extensions:
115 118 return True
116 119 if ext.startswith('.'):
117 120 ext = ext[1:]
118 121 return ext.lower() in extensions
119 122
120 123 def save_file(self, file_obj, filename, directory=None, extensions=None,
121 124 metadata=None, **kwargs):
122 125 """
123 126 Saves a file object to the uploads location.
124 127 Returns the resolved filename, i.e. the directory +
125 128 the (randomized/incremented) base name.
126 129
127 130 :param file_obj: **cgi.FieldStorage** object (or similar)
128 131 :param filename: original filename
129 132 :param directory: relative path of sub-directory
130 133 :param extensions: iterable of allowed extensions, if not default
131 134 :param metadata: JSON metadata to store next to the file with .meta suffix
132 135 :returns: modified filename
133 136 """
134 137
135 138 extensions = extensions or self.extensions
136 139
137 140 if not self.filename_allowed(filename, extensions):
138 141 raise FileNotAllowedException()
139 142
140 143 if directory:
141 144 dest_directory = os.path.join(self.base_path, directory)
142 145 else:
143 146 dest_directory = self.base_path
144 147
145 148 if not os.path.exists(dest_directory):
146 149 os.makedirs(dest_directory)
147 150
148 151 filename = utils.uid_filename(filename)
149 152
150 153 filename, path = self.resolve_name(filename, dest_directory)
151 154 filename_meta = filename + '.meta'
152 155
153 156 file_obj.seek(0)
154 157
155 158 with open(path, "wb") as dest:
156 159 shutil.copyfileobj(file_obj, dest)
157 160
158 161 if metadata:
159 162 size = os.stat(path).st_size
160 metadata.update({'size': size})
163 metadata.update({'size': size, "time": time.time(),
164 "meta_ver": METADATA_VER})
161 165 with open(os.path.join(dest_directory, filename_meta), "wb") as dest_meta:
162 166 dest_meta.write(json.dumps(metadata))
163 167
164 168 if directory:
165 169 filename = os.path.join(directory, filename)
166 170
167 171 return filename
General Comments 0
You need to be logged in to leave comments. Login now