Skip to content

Commit 34d9974

Browse files
committed
Merge remote-tracking branch 'origin/master' into edge
2 parents 0750457 + 3766076 commit 34d9974

File tree

6 files changed

+162
-28
lines changed

6 files changed

+162
-28
lines changed

mig/images/js/jquery.filemanager.js

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,6 +1013,61 @@ if (jQuery) (function($){
10131013
);
10141014
}
10151015

1016+
function downloadWrapper(el, dialog, download_url) {
1017+
var lastinfo = $(statusinfo).html();
1018+
1019+
var target = $(el).attr(pathAttribute).split('/');
1020+
var file_name;
1021+
if (target.lastIndexOf('/') === target.length-1) {
1022+
file_name = 'output.bin';
1023+
} else {
1024+
file_name = target[target.length-1];
1025+
}
1026+
startProgress("Downloading "+file_name);
1027+
1028+
$.ajax({
1029+
url: download_url,
1030+
dataType: 'binary',
1031+
method: 'GET',
1032+
xhrFields: {
1033+
responseType: 'blob' // Set the response type to 'blob'
1034+
},
1035+
success: function(blob) {
1036+
console.info("Starting download of "+file_name)
1037+
let fileURL = URL.createObjectURL(blob);
1038+
var link = document.createElement('a');
1039+
link.href = fileURL;
1040+
/* downloaded filename */
1041+
link.download = file_name;
1042+
document.body.appendChild(link);
1043+
link.click();
1044+
URL.revokeObjectURL(fileURL);
1045+
stopProgress("");
1046+
},
1047+
error: function(xhr, textStatus, errorThrown) {
1048+
console.error('Error downloading', file_name, ":", textStatus, ":", errorThrown);
1049+
//console.info('xhr: ', xhr);
1050+
//console.info('xhr.status: ', xhr.status);
1051+
1052+
var hint_txt = "";
1053+
/* NOTE: this error code is from cat and in returnvalues */
1054+
/* TODO: can we extract actual response from xhr instead? */
1055+
if (xhr.status === 422) {
1056+
hint_txt = "above web size limit"
1057+
} else {
1058+
hint_txt = textStatus +" ("+errorThrown+")";
1059+
}
1060+
var err_msg = "Download failed: "+hint_txt;
1061+
stopProgress(err_msg);
1062+
1063+
err_msg += ". Please use other protocols for big downloads.";
1064+
$(dialog).dialog(okDialog);
1065+
$(dialog).dialog('open');
1066+
$(dialog).html(err_msg);
1067+
}
1068+
});
1069+
}
1070+
10161071
// Callback helpers for context menu
10171072
var callbacks = {
10181073

@@ -1024,8 +1079,9 @@ if (jQuery) (function($){
10241079
var path = chroot($(el).attr(pathAttribute), options);
10251080
if (options.enableGDP) {
10261081
/* Path may contain URL-unfriendly characters */
1027-
document.location = 'cat.py?path=' +
1082+
var download_url = 'cat.py?path=' +
10281083
encodeURIComponent(path)+'&output_format=file';
1084+
downloadWrapper(el, '#cmd_dialog', download_url);
10291085
}
10301086
else {
10311087
var path_enc = encodeURI(path);
@@ -1057,8 +1113,9 @@ if (jQuery) (function($){
10571113
window.open('/cert_redirect/'+path_enc);
10581114
} else {
10591115
/* Path may contain URL-unfriendly characters */
1060-
document.location = 'cat.py?path=' +
1116+
var download_url = 'cat.py?path=' +
10611117
encodeURIComponent(path)+'&output_format=file';
1118+
downloadWrapper(el, '#cmd_dialog', download_url);
10621119
}
10631120
},
10641121
edit: function (action, el, pos) {

mig/shared/functionality/cat.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
write_file_lines
4242
from mig.shared.functional import validate_input_and_cert, REJECT_UNSET
4343
from mig.shared.handlers import safe_handler, get_csrf_limit
44-
from mig.shared.init import initialize_main_variables, start_download
44+
from mig.shared.init import initialize_main_variables, find_entry, \
45+
make_start_entry, start_error, start_download
4546
from mig.shared.parseflags import verbose, binary
4647
from mig.shared.userio import GDPIOLogError, gdp_iolog
4748
from mig.shared.safeinput import valid_path_pattern
@@ -112,8 +113,9 @@ def _main(configuration, logger, environ, op_name='', output_objects=None, clien
112113
if logger is None:
113114
logger = configuration.logger
114115

116+
# Create new output_objects list with start entry if None was supplied
115117
if output_objects is None:
116-
output_objects = [] # create a new list if one was not supplied
118+
output_objects = [make_start_entry()]
117119

118120
client_dir = client_id_dir(client_id)
119121
defaults = signature()[1]
@@ -142,6 +144,9 @@ def _main(configuration, logger, environ, op_name='', output_objects=None, clien
142144
base_dir = os.path.abspath(os.path.join(configuration.user_home,
143145
client_dir)) + os.sep
144146

147+
output_format = user_arguments_dict.get('output_format', ['txt'])[0]
148+
start_entry = find_entry(output_objects, 'start')
149+
145150
if verbose(flags):
146151
for flag in flags:
147152
output_objects.append({'object_type': 'text',
@@ -150,28 +155,34 @@ def _main(configuration, logger, environ, op_name='', output_objects=None, clien
150155
if dst:
151156
if not safe_handler(configuration, 'post', op_name, client_id,
152157
get_csrf_limit(configuration), accepted):
158+
status = returnvalues.REJECT_PROCESSING_ERROR
159+
error_marker = start_error(configuration, output_format, status)
160+
start_entry.update(error_marker)
153161
output_objects.append(
154-
{'object_type': 'error_text', 'text': '''Only accepting
155-
CSRF-filtered POST requests to prevent unintended updates'''
162+
{'object_type': 'error_text', 'text': 'Only accepting '
163+
'CSRF-filtered POST requests to prevent unintended updates'
156164
})
157-
return (output_objects, returnvalues.CLIENT_ERROR)
165+
return (output_objects, status)
158166

159167
# IMPORTANT: path must be expanded to abs for proper chrooting
160168
abs_dest = os.path.abspath(os.path.join(base_dir, dst))
161169
relative_dst = abs_dest.replace(base_dir, '')
162170
if not valid_user_path(configuration, abs_dest, base_dir, True):
163171
logger.warning('%s tried to %s into restricted path %s ! (%s)'
164172
% (client_id, op_name, abs_dest, dst))
173+
status = returnvalues.FORBIDDEN_ERROR
174+
error_marker = start_error(configuration, output_format, status)
175+
start_entry.update(error_marker)
165176
output_objects.append({'object_type': 'error_text',
166177
'text': "invalid destination: '%s'"
167178
% dst})
168-
return (output_objects, returnvalues.CLIENT_ERROR)
179+
return (output_objects, status)
169180

170181
src_mode = "rb"
171182
dst_mode = "wb"
172183
if binary(flags):
173184
force_file = True
174-
elif user_arguments_dict.get('output_format', ['txt'])[0] == 'file':
185+
elif output_format == 'file':
175186
force_file = True
176187
else:
177188
force_file = False
@@ -204,13 +215,17 @@ def _main(configuration, logger, environ, op_name='', output_objects=None, clien
204215
# (allowed) match
205216

206217
if not match:
218+
status = returnvalues.NOT_FOUND_ERROR
219+
error_marker = start_error(configuration, output_format, status)
220+
start_entry.update(error_marker)
207221
output_objects.append({'object_type': 'file_not_found',
208222
'name': pattern})
209-
status = returnvalues.FILE_NOT_FOUND
210223

211224
if not _check_serve_permitted(configuration, paths=match):
212-
status = returnvalues.REJECTED_ERROR
225+
status = returnvalues.REJECT_PROCESSING_ERROR
213226
text = _render_error_text_for_serve_limit(configuration)
227+
error_marker = start_error(configuration, output_format, status)
228+
start_entry.update(error_marker)
214229
output_objects.append({'object_type': 'error_text', 'text': text})
215230
return (output_objects, status)
216231

@@ -299,7 +314,7 @@ def _main(configuration, logger, environ, op_name='', output_objects=None, clien
299314
if force_file:
300315
download_marker = start_download(configuration, abs_path,
301316
output_lines)
302-
output_objects.append(download_marker)
317+
start_entry.update(download_marker)
303318
output_objects.append(entry)
304319

305320
return (output_objects, status)

mig/shared/init.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# --- BEGIN_HEADER ---
55
#
66
# init - shared helpers to init functionality backends
7-
# Copyright (C) 2003-2023 The MiG Project lead by Brian Vinter
7+
# Copyright (C) 2003-2024 The MiG Project lead by Brian Vinter
88
#
99
# This file is part of MiG.
1010
#
@@ -100,6 +100,22 @@ def start_download(configuration, path, output):
100100
% os.path.basename(path))])
101101

102102

103+
def start_error(configuration, output_format, status_pair):
104+
"""Helper to set the headers required to force an error message to be shown
105+
in particular in cases where file or binary output delivery was requested,
106+
which would hide the error in the file.
107+
"""
108+
_logger = configuration.logger
109+
if output_format in ['file', 'binary']:
110+
content_type = 'text/plain'
111+
else:
112+
content_type = output_format
113+
_logger.debug('force %s error message for %s request' % (content_type,
114+
output_format))
115+
return make_start_entry([('Content-Type', content_type),
116+
('Status', '%s %s' % status_pair)])
117+
118+
103119
def initialize_main_variables(client_id, op_title=True, op_header=True,
104120
op_menu=True):
105121
"""Script initialization is identical for most scripts in

mig/shared/output.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# --- BEGIN_HEADER ---
55
#
66
# output - general formatting of backend output objects
7-
# Copyright (C) 2003-2023 The MiG Project lead by Brian Vinter
7+
# Copyright (C) 2003-2024 The MiG Project lead by Brian Vinter
88
#
99
# This file is part of MiG.
1010
#
@@ -41,6 +41,7 @@
4141
from mig.shared.defaults import file_dest_sep, keyword_any, keyword_updating
4242
from mig.shared.htmlgen import get_xgi_html_header, get_xgi_html_footer, \
4343
vgrid_items, html_post_helper, tablesorter_pager
44+
from mig.shared.init import find_entry
4445
from mig.shared.objecttypes import validate
4546
from mig.shared.prettyprinttable import pprint_table
4647
from mig.shared.pwcrypto import sorted_hash_algos
@@ -2716,16 +2717,55 @@ def resource_format(configuration, ret_val, ret_msg, out_obj):
27162717
def file_format(configuration, ret_val, ret_msg, out_obj):
27172718
"""Dump raw file contents"""
27182719

2720+
_logger = configuration.logger
2721+
27192722
# TODO: use wsgi file_wrapper helper here if out_obj has wsgi entry?
27202723

27212724
file_content = ''
27222725

2726+
# NOTE: carefully handle errors and ONLY render them when proper care has
2727+
# been taken to deliver them as actual output, to avoid that they end
2728+
# up hidden inside downloaded files.
2729+
# Mark error output with the explicit start_error helper in backends
2730+
# like it's done in cat.py, and only log such entries as warnings
2731+
# here otherwise.
2732+
render_text, render_errors = False, False
2733+
headers = []
2734+
content_type = 'application/octet-stream'
2735+
start_entry = find_entry(out_obj, 'start')
2736+
if start_entry is not None:
2737+
headers = start_entry.get('headers', [])
2738+
for (key, val) in headers:
2739+
if key == 'Content-Type':
2740+
content_type = val
2741+
if content_type in ('text/plain', 'text/html'):
2742+
render_text, render_errors = True, True
2743+
_logger.debug("render output in file_format: %s (%s %s)" %
2744+
(out_obj, render_text, render_errors))
27232745
for entry in out_obj:
27242746
if entry['object_type'] == 'file_output':
27252747
for line in entry['lines']:
27262748
file_content += line
27272749
elif entry['object_type'] == 'binary':
27282750
file_content = entry['data']
2751+
elif entry['object_type'] == 'text':
2752+
if render_text:
2753+
file_content += entry['text']
2754+
else:
2755+
_logger.warning("skip %(object_type)s in file_format: %(text)s"
2756+
% entry)
2757+
elif entry['object_type'] == 'error_text':
2758+
if render_errors:
2759+
file_content += entry['text']
2760+
else:
2761+
_logger.warning("skip %(object_type)s in file_format: %(text)s"
2762+
% entry)
2763+
elif entry['object_type'] == 'file_not_found':
2764+
if render_errors:
2765+
file_content += '%(name)s: No such file or directory\n' % entry
2766+
else:
2767+
_logger.warning("skip %(object_type)s in file_format: %(name)s"
2768+
% entry)
27292769

27302770
return file_content
27312771

mig/shared/returnvalues.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,11 @@
4545
SYSTEM_ERROR = (200, 'SYSTEM_ERROR')
4646
USER_NOT_CREATED = (201, 'USER_NOT_CREATED')
4747
OUTPUT_VALIDATION_ERROR = (202, 'The output the server '
48-
+ 'has generated could not be validated')
48+
+ 'has generated could not be validated')
4949

5050
# REQUEST ERRORS
5151

52-
REJECTED_ERROR = (422, 'REJECTED')
52+
BAD_REQUEST_ERROR = (400, 'Bad Request')
53+
FORBIDDEN_ERROR = (403, 'Forbidden')
54+
NOT_FOUND_ERROR = (404, 'Not Found')
55+
REJECT_PROCESSING_ERROR = (422, 'Unprocessable Content')

tests/test_mig_shared_functionality_cat.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ def test_file_serving_a_single_file_match(self):
109109
user_arguments_dict=payload,
110110
environ=self.test_environ)
111111

112-
self.assertEqual(len(output_objects), 1)
112+
# NOTE: start entry with headers and actual content
113+
self.assertEqual(len(output_objects), 2)
113114
self.assertSingleOutputObject(output_objects,
114115
with_object_type='file_output')
115116

@@ -161,14 +162,14 @@ def test_file_serving_over_limit_without_storage_protocols(self):
161162
user_arguments_dict=payload,
162163
environ=self.test_environ)
163164

164-
self.assertEqual(len(output_objects), 1)
165+
# NOTE: start entry with headers and actual error message
166+
self.assertEqual(len(output_objects), 2)
165167
relevant_obj = self.assertSingleOutputObject(output_objects,
166-
with_object_type='error_text')
168+
with_object_type='error_text')
167169
self.assertEqual(relevant_obj['text'],
168170
"Site configuration prevents web serving contents "
169171
"bigger than 3896 bytes")
170172

171-
172173
def test_file_serving_over_limit_with_storage_protocols_sftp(self):
173174
test_binary_file = os.path.realpath(os.path.join(TEST_DATA_DIR,
174175
'loading.gif'))
@@ -186,13 +187,13 @@ def test_file_serving_over_limit_with_storage_protocols_sftp(self):
186187
self.configuration.wwwserve_max_bytes = test_binary_file_size - 1
187188

188189
(output_objects, status) = submain(self.configuration, self.logger,
189-
client_id=self.TEST_CLIENT_ID,
190-
user_arguments_dict=payload,
191-
environ=self.test_environ)
190+
client_id=self.TEST_CLIENT_ID,
191+
user_arguments_dict=payload,
192+
environ=self.test_environ)
192193

193-
self.assertEqual(len(output_objects), 1)
194+
# NOTE: start entry with headers and actual error message
194195
relevant_obj = self.assertSingleOutputObject(output_objects,
195-
with_object_type='error_text')
196+
with_object_type='error_text')
196197
self.assertEqual(relevant_obj['text'],
197198
"Site configuration prevents web serving contents "
198199
"bigger than 3896 bytes - please use better "
@@ -203,15 +204,17 @@ def test_main_passes_environ(self):
203204
try:
204205
result = realmain(self.TEST_CLIENT_ID, {}, None)
205206
except Exception as unexpectedexc:
206-
raise AssertionError("saw unexpected exception: %s" % (unexpectedexc,))
207+
raise AssertionError(
208+
"saw unexpected exception: %s" % (unexpectedexc,))
207209

208210
(output_objects, status) = result
209211
self.assertEqual(status[1], 'Client error')
210212

211213
error_text_objects = _only_output_objects(output_objects,
212-
with_object_type='error_text')
214+
with_object_type='error_text')
213215
relevant_obj = error_text_objects[2]
214-
self.assertEqual(relevant_obj['text'], 'Input arguments were rejected - not allowed for this script!')
216+
self.assertEqual(
217+
relevant_obj['text'], 'Input arguments were rejected - not allowed for this script!')
215218

216219

217220
if __name__ == '__main__':

0 commit comments

Comments
 (0)