parent
418df1562c
commit
485fd773fc
@ -0,0 +1,145 @@ |
|||||||
|
import logging |
||||||
|
import os |
||||||
|
import sys |
||||||
|
import signal |
||||||
|
import time |
||||||
|
import errno |
||||||
|
import atexit |
||||||
|
|
||||||
|
|
||||||
|
class DaemonError(Exception): |
||||||
|
pass |
||||||
|
|
||||||
|
|
||||||
|
class Daemon: # pragma: no cover |
||||||
|
""" |
||||||
|
Daemonize the current process (detach it from the console). |
||||||
|
""" |
||||||
|
def __init__(self, pid_file, log_file=None, log_level=logging.INFO, |
||||||
|
foreground=False): |
||||||
|
if pid_file is None: |
||||||
|
self.pid_file = '{0}.pid'.format(os.path.basename(sys.argv[0])) |
||||||
|
else: |
||||||
|
self.pid_file = pid_file |
||||||
|
if log_file is None: |
||||||
|
self.log_file = '{0}.log'.format(os.path.basename(sys.argv[0])) |
||||||
|
else: |
||||||
|
self.log_file = log_file |
||||||
|
self.foreground = foreground |
||||||
|
|
||||||
|
logging.basicConfig(level=log_level, |
||||||
|
format='%(asctime)s:%(name)s:%(levelname)s:%(message)s', |
||||||
|
filename=self.log_file, |
||||||
|
filemode='a') |
||||||
|
self.log = logging.getLogger('DAEMON') |
||||||
|
self.shutdown_cb = None |
||||||
|
|
||||||
|
def register_shutdown_cb(self, cb): |
||||||
|
self.shutdown_cb = cb |
||||||
|
|
||||||
|
def start(self): |
||||||
|
self.log.info("Starting") |
||||||
|
if self.is_running(): |
||||||
|
self.log.error('Already running') |
||||||
|
raise DaemonError("Already running") |
||||||
|
if not self.foreground: |
||||||
|
self._fork() |
||||||
|
|
||||||
|
def stop(self): |
||||||
|
if not self.is_running(): |
||||||
|
raise DaemonError("Not running") |
||||||
|
|
||||||
|
pid = self.get_pid() |
||||||
|
|
||||||
|
# Kill the daemon and wait until the process is gone |
||||||
|
os.kill(pid, signal.SIGTERM) |
||||||
|
for timeout in range(25): # 5 seconds |
||||||
|
time.sleep(0.2) |
||||||
|
if not self._pid_running(pid): |
||||||
|
break |
||||||
|
else: |
||||||
|
self.log.error("Couldn't stop the daemon.") |
||||||
|
|
||||||
|
def is_running(self): |
||||||
|
""" |
||||||
|
Check if the daemon is already running by looking at the PID file |
||||||
|
""" |
||||||
|
if self.get_pid() is None: |
||||||
|
return False |
||||||
|
else: |
||||||
|
return True |
||||||
|
|
||||||
|
def get_pid(self): |
||||||
|
""" |
||||||
|
Returns the PID of this daemon. If the daemon is not running (the PID |
||||||
|
file does not exist or the PID in the PID file does not exist), returns |
||||||
|
None. |
||||||
|
""" |
||||||
|
if not os.path.exists(self.pid_file): |
||||||
|
return None |
||||||
|
|
||||||
|
try: |
||||||
|
pid = int(file(self.pid_file, 'r').read().strip()) |
||||||
|
except ValueError: |
||||||
|
return None |
||||||
|
|
||||||
|
if os.path.isdir('/proc/{0}/'.format(pid)): |
||||||
|
return pid |
||||||
|
else: |
||||||
|
os.unlink(self.pid_file) |
||||||
|
return None |
||||||
|
|
||||||
|
def _pid_running(self, pid): |
||||||
|
""" |
||||||
|
Returns True if the PID is running, False otherwise |
||||||
|
""" |
||||||
|
try: |
||||||
|
os.kill(pid, 0) |
||||||
|
except OSError as err: |
||||||
|
if err.errno == errno.ESRCH: |
||||||
|
return False |
||||||
|
return True |
||||||
|
|
||||||
|
def _fork(self): |
||||||
|
# Fork a child and end the parent (detach from parent) |
||||||
|
pid = os.fork() |
||||||
|
if pid > 0: |
||||||
|
sys.exit(0) # End parent |
||||||
|
|
||||||
|
# Change some defaults so the daemon doesn't tie up dirs, etc. |
||||||
|
os.setsid() |
||||||
|
os.umask(0) |
||||||
|
|
||||||
|
# Fork a child and end parent (so init now owns process) |
||||||
|
pid = os.fork() |
||||||
|
if pid > 0: |
||||||
|
self.log.info("PID = {0}".format(pid)) |
||||||
|
f = file(self.pid_file, 'w') |
||||||
|
f.write(str(pid)) |
||||||
|
f.close() |
||||||
|
sys.exit(0) # End parent |
||||||
|
|
||||||
|
atexit.register(self._cleanup) |
||||||
|
signal.signal(signal.SIGTERM, self._cleanup) |
||||||
|
|
||||||
|
# Close STDIN, STDOUT and STDERR so we don't tie up the controlling |
||||||
|
# terminal |
||||||
|
for fd in (0, 1, 2): |
||||||
|
try: |
||||||
|
os.close(fd) |
||||||
|
except OSError: |
||||||
|
pass |
||||||
|
|
||||||
|
# Reopen the closed file descriptors so other os.open() calls don't |
||||||
|
# accidentally get tied to the stdin etc. |
||||||
|
os.open("/dev/null", os.O_RDWR) # standard input (0) |
||||||
|
os.dup2(0, 1) # standard output (1) |
||||||
|
os.dup2(0, 2) # standard error (2) |
||||||
|
|
||||||
|
return pid |
||||||
|
|
||||||
|
def _cleanup(self, signal=None, frame=None): |
||||||
|
self.log.info("Received signal {0}".format(signal)) |
||||||
|
if os.path.exists(self.pid_file): |
||||||
|
os.unlink(self.pid_file) |
||||||
|
self.shutdown_cb() |
@ -0,0 +1,96 @@ |
|||||||
|
import logging |
||||||
|
import stat |
||||||
|
import os |
||||||
|
import subprocess |
||||||
|
|
||||||
|
|
||||||
|
class FormConfigError(Exception): |
||||||
|
pass |
||||||
|
|
||||||
|
|
||||||
|
class FormConfig: |
||||||
|
""" |
||||||
|
FormConfig is the in-memory representation of a form configuration JSON |
||||||
|
file. It holds information (title, users, the form definitions) on the |
||||||
|
form configuration being served by this instance of ScriptForm. |
||||||
|
""" |
||||||
|
def __init__(self, title, forms, users={}, static_dir=None, custom_css=None): |
||||||
|
self.title = title |
||||||
|
self.users = users |
||||||
|
self.forms = forms |
||||||
|
self.static_dir = static_dir |
||||||
|
self.custom_css = custom_css |
||||||
|
self.log = logging.getLogger('FORMCONFIG') |
||||||
|
|
||||||
|
# Validate scripts |
||||||
|
for form_def in self.forms: |
||||||
|
if not stat.S_IXUSR & os.stat(form_def.script)[stat.ST_MODE]: |
||||||
|
raise FormConfigError("{0} is not executable".format(form_def.script)) |
||||||
|
|
||||||
|
def get_form_def(self, form_name): |
||||||
|
""" |
||||||
|
Return the form definition for the form with name `form_name`. Returns |
||||||
|
an instance of FormDefinition class or raises ValueError if the form |
||||||
|
was not found. |
||||||
|
""" |
||||||
|
for form_def in self.forms: |
||||||
|
if form_def.name == form_name: |
||||||
|
return form_def |
||||||
|
else: |
||||||
|
raise ValueError("No such form: {0}".format(form_name)) |
||||||
|
|
||||||
|
def get_visible_forms(self, username=None): |
||||||
|
""" |
||||||
|
Return a list of all visible forms. Excluded forms are those that have |
||||||
|
the 'hidden' property set, and where the user has no access to. |
||||||
|
""" |
||||||
|
form_list = [] |
||||||
|
for form_def in self.forms: |
||||||
|
if form_def.allowed_users is not None and \ |
||||||
|
username not in form_def.allowed_users: |
||||||
|
continue # User is not allowed to run this form |
||||||
|
if form_def.hidden: |
||||||
|
continue # Don't show hidden forms in the list. |
||||||
|
else: |
||||||
|
form_list.append(form_def) |
||||||
|
return form_list |
||||||
|
|
||||||
|
def callback(self, form_name, form_values, stdout=None, stderr=None): |
||||||
|
""" |
||||||
|
Perform a callback for the form `form_name`. This calls a script. |
||||||
|
`form_values` is a dictionary of validated values as returned by |
||||||
|
FormDefinition.validate(). If form.output is of type 'raw', `stdout` |
||||||
|
and `stderr` have to be open filehandles where the output of the |
||||||
|
callback should be written. The output of the script is hooked up to |
||||||
|
the output, depending on the output type. |
||||||
|
""" |
||||||
|
form = self.get_form_def(form_name) |
||||||
|
|
||||||
|
# Validate params |
||||||
|
if form.output == 'raw' and (stdout is None or stderr is None): |
||||||
|
raise ValueError('stdout and stderr cannot be None if script output is \'raw\'') |
||||||
|
|
||||||
|
# Pass form values to the script through the environment as strings. |
||||||
|
env = os.environ.copy() |
||||||
|
for k, v in form_values.items(): |
||||||
|
env[k] = str(v) |
||||||
|
|
||||||
|
# If the form output type is 'raw', we directly stream the output to |
||||||
|
# the browser. Otherwise we store it for later displaying. |
||||||
|
if form.output == 'raw': |
||||||
|
p = subprocess.Popen(form.script, shell=True, |
||||||
|
stdout=stdout, |
||||||
|
stderr=stderr, |
||||||
|
env=env) |
||||||
|
stdout, stderr = p.communicate(input) |
||||||
|
return p.returncode |
||||||
|
else: |
||||||
|
p = subprocess.Popen(form.script, shell=True, stdin=subprocess.PIPE, |
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, |
||||||
|
env=env) |
||||||
|
stdout, stderr = p.communicate() |
||||||
|
return { |
||||||
|
'stdout': stdout, |
||||||
|
'stderr': stderr, |
||||||
|
'exitcode': p.returncode |
||||||
|
} |
@ -0,0 +1,195 @@ |
|||||||
|
import os |
||||||
|
import datetime |
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(Exception): |
||||||
|
pass |
||||||
|
|
||||||
|
|
||||||
|
class FormDefinition: |
||||||
|
""" |
||||||
|
FormDefinition holds information about a single form and provides methods |
||||||
|
for validation of the form values. |
||||||
|
""" |
||||||
|
def __init__(self, name, title, description, fields, script, |
||||||
|
output='escaped', hidden=False, submit_title="Submit", |
||||||
|
allowed_users=None): |
||||||
|
self.name = name |
||||||
|
self.title = title |
||||||
|
self.description = description |
||||||
|
self.fields = fields |
||||||
|
self.script = script |
||||||
|
self.output = output |
||||||
|
self.hidden = hidden |
||||||
|
self.submit_title = submit_title |
||||||
|
self.allowed_users = allowed_users |
||||||
|
|
||||||
|
def get_field_def(self, field_name): |
||||||
|
for field in self.fields: |
||||||
|
if field['name'] == field_name: |
||||||
|
return field |
||||||
|
raise KeyError("Unknown field: {0}".format(field_name)) |
||||||
|
|
||||||
|
def validate(self, form_values): |
||||||
|
""" |
||||||
|
Validate all relevant fields for this form against form_values. Returns |
||||||
|
a set with the errors and new values. |
||||||
|
""" |
||||||
|
errors = {} |
||||||
|
values = form_values.copy() |
||||||
|
|
||||||
|
# First make sure all required fields are there |
||||||
|
for field in self.fields: |
||||||
|
if 'required' in field and \ |
||||||
|
field['required'] is True and \ |
||||||
|
(field['name'] not in form_values or form_values[field['name']] == ''): |
||||||
|
errors.setdefault(field['name'], []).append( |
||||||
|
"This field is required" |
||||||
|
) |
||||||
|
|
||||||
|
# Validate the field values, possible casting them to the correct type. |
||||||
|
for field in self.fields: |
||||||
|
field_name = field['name'] |
||||||
|
if field_name in errors: |
||||||
|
# Skip fields that are required but missing, since they can't be validated |
||||||
|
continue |
||||||
|
try: |
||||||
|
v = self._field_validate(field_name, form_values) |
||||||
|
if v is not None: |
||||||
|
values[field_name] = v |
||||||
|
except ValidationError, e: |
||||||
|
errors.setdefault(field_name, []).append(str(e)) |
||||||
|
|
||||||
|
return (errors, values) |
||||||
|
|
||||||
|
def _field_validate(self, field_name, form_values): |
||||||
|
""" |
||||||
|
Validate a field in this form. This does a dynamic call to a method on |
||||||
|
this class in the form 'validate_<field_type>'. |
||||||
|
""" |
||||||
|
# Find field definition by iterating through all the fields. |
||||||
|
field_def = self.get_field_def(field_name) |
||||||
|
|
||||||
|
field_type = field_def['type'] |
||||||
|
validate_cb = getattr(self, 'validate_{0}'.format(field_type), None) |
||||||
|
return validate_cb(field_def, form_values) |
||||||
|
|
||||||
|
def validate_string(self, field_def, form_values): |
||||||
|
value = form_values[field_def['name']] |
||||||
|
maxlen = field_def.get('maxlen', None) |
||||||
|
minlen = field_def.get('minlen', None) |
||||||
|
|
||||||
|
if minlen is not None and len(value) < int(minlen): |
||||||
|
raise ValidationError("Minimum length is {0}".format(minlen)) |
||||||
|
if maxlen is not None and len(value) > int(maxlen): |
||||||
|
raise ValidationError("Maximum length is {0}".format(maxlen)) |
||||||
|
|
||||||
|
return value |
||||||
|
|
||||||
|
def validate_integer(self, field_def, form_values): |
||||||
|
value = form_values[field_def['name']] |
||||||
|
max = field_def.get('max', None) |
||||||
|
min = field_def.get('min', None) |
||||||
|
|
||||||
|
try: |
||||||
|
value = int(value) |
||||||
|
except ValueError: |
||||||
|
raise ValidationError("Must be an integer number") |
||||||
|
|
||||||
|
if min is not None and value < int(min): |
||||||
|
raise ValidationError("Minimum value is {0}".format(min)) |
||||||
|
if max is not None and value > int(max): |
||||||
|
raise ValidationError("Maximum value is {0}".format(max)) |
||||||
|
|
||||||
|
return int(value) |
||||||
|
|
||||||
|
def validate_float(self, field_def, form_values): |
||||||
|
value = form_values[field_def['name']] |
||||||
|
max = field_def.get('max', None) |
||||||
|
min = field_def.get('min', None) |
||||||
|
|
||||||
|
try: |
||||||
|
value = float(value) |
||||||
|
except ValueError: |
||||||
|
raise ValidationError("Must be an real (float) number") |
||||||
|
|
||||||
|
if min is not None and value < float(min): |
||||||
|
raise ValidationError("Minimum value is {0}".format(min)) |
||||||
|
if max is not None and value > float(max): |
||||||
|
raise ValidationError("Maximum value is {0}".format(max)) |
||||||
|
|
||||||
|
return float(value) |
||||||
|
|
||||||
|
def validate_date(self, field_def, form_values): |
||||||
|
value = form_values[field_def['name']] |
||||||
|
max = field_def.get('max', None) |
||||||
|
min = field_def.get('min', None) |
||||||
|
|
||||||
|
try: |
||||||
|
value = datetime.datetime.strptime(value, '%Y-%m-%d').date() |
||||||
|
except ValueError: |
||||||
|
raise ValidationError("Invalid date, must be in form YYYY-MM-DD") |
||||||
|
|
||||||
|
if min is not None: |
||||||
|
if value < datetime.datetime.strptime(min, '%Y-%m-%d').date(): |
||||||
|
raise ValidationError("Minimum value is {0}".format(min)) |
||||||
|
if max is not None: |
||||||
|
if value > datetime.datetime.strptime(max, '%Y-%m-%d').date(): |
||||||
|
raise ValidationError("Maximum value is {0}".format(max)) |
||||||
|
|
||||||
|
return value |
||||||
|
|
||||||
|
def validate_radio(self, field_def, form_values): |
||||||
|
value = form_values[field_def['name']] |
||||||
|
if not value in [o[0] for o in field_def['options']]: |
||||||
|
raise ValidationError( |
||||||
|
"Invalid value for radio button: {0}".format(value)) |
||||||
|
return value |
||||||
|
|
||||||
|
def validate_select(self, field_def, form_values): |
||||||
|
value = form_values[field_def['name']] |
||||||
|
if not value in [o[0] for o in field_def['options']]: |
||||||
|
raise ValidationError( |
||||||
|
"Invalid value for dropdown: {0}".format(value)) |
||||||
|
return value |
||||||
|
|
||||||
|
def validate_checkbox(self, field_def, form_values): |
||||||
|
value = form_values.get(field_def['name'], 'off') |
||||||
|
if not value in ['on', 'off']: |
||||||
|
raise ValidationError( |
||||||
|
"Invalid value for checkbox: {0}".format(value)) |
||||||
|
return value |
||||||
|
|
||||||
|
def validate_text(self, field_def, form_values): |
||||||
|
value = form_values[field_def['name']] |
||||||
|
minlen = field_def.get('minlen', None) |
||||||
|
maxlen = field_def.get('maxlen', None) |
||||||
|
|
||||||
|
if minlen is not None and len(value) < int(minlen): |
||||||
|
raise ValidationError("minimum length is {0}".format(minlen)) |
||||||
|
|
||||||
|
if maxlen is not None and len(value) > int(maxlen): |
||||||
|
raise ValidationError("maximum length is {0}".format(maxlen)) |
||||||
|
|
||||||
|
return value |
||||||
|
|
||||||
|
def validate_password(self, field_def, form_values): |
||||||
|
value = form_values[field_def['name']] |
||||||
|
minlen = field_def.get('minlen', None) |
||||||
|
|
||||||
|
if minlen is not None and len(value) < int(minlen): |
||||||
|
raise ValidationError("minimum length is {0}".format(minlen)) |
||||||
|
|
||||||
|
return value |
||||||
|
|
||||||
|
def validate_file(self, field_def, form_values): |
||||||
|
value = form_values[field_def['name']] |
||||||
|
field_name = field_def['name'] |
||||||
|
upload_fname = form_values[u'{0}__name'.format(field_name)] |
||||||
|
upload_fname_ext = os.path.splitext(upload_fname)[-1].lstrip('.') |
||||||
|
extensions = field_def.get('extensions', None) |
||||||
|
|
||||||
|
if extensions is not None and upload_fname_ext not in extensions: |
||||||
|
raise ValidationError("Only file types allowed: {0}".format(u','.join(extensions))) |
||||||
|
|
||||||
|
return value |
@ -0,0 +1,127 @@ |
|||||||
|
html_field = u''' |
||||||
|
<li class="{classes}"> |
||||||
|
<p class="form-field-title">{title}</p> |
||||||
|
<p class="form-field-input">{input} <span class="error">{errors}</span></p> |
||||||
|
</li> |
||||||
|
''' |
||||||
|
|
||||||
|
html_field_checkbox = u''' |
||||||
|
<li class="checkbox {classes}"> |
||||||
|
<p class="form-field-input">{input} <p class="form-field-title">{title}</p><span class="error">{errors}</span></p> |
||||||
|
</li> |
||||||
|
''' |
||||||
|
|
||||||
|
|
||||||
|
class FormRender(): |
||||||
|
field_tpl = { |
||||||
|
"string": u'<input {required} type="text" name="{name}" value="{value}" size="{size}" class="{classes}" style="{style}" />', |
||||||
|
"number": u'<input {required} type="number" min="{min}" max="{max}" name="{name}" value="{value}" class="{classes}" style="{style}" />', |
||||||
|
"integer": u'<input {required} type="number" min="{min}" max="{max}" name="{name}" value="{value}" class="{classes}" style="{style}" />', |
||||||
|
"float": u'<input {required} type="number" min="{min}" max="{max}" step="any" name="{name}" value="{value}" class="{classes}" style="{style}" />', |
||||||
|
"date": u'<input {required} type="date" name="{name}" value="{value}" class="{classes}" style="{style}" />', |
||||||
|
"file": u'<input {required} type="file" name="{name}" class="{classes}" style="{style}" />', |
||||||
|
"password": u'<input {required} type="password" min="{min}" name="{name}" value="{value}" class="{classes}" style="{style}" />', |
||||||
|
"text": u'<textarea {required} name="{name}" rows="{rows}" cols="{cols}" style="{style}" class="{classes}">{value}</textarea>', |
||||||
|
"radio_option": u'<input {checked} type="radio" name="{name}" value="{value}" class="{classes} style="{style}"">{label}<br/>', |
||||||
|
"select_option": u'<option value="{value}" style="{style}" {selected}>{label}</option>', |
||||||
|
"select": u'<select name="{name}" class="{classes}" style="{style}">{select_elems}</select>', |
||||||
|
"checkbox": u'<input {checked} type="checkbox" name="{name}" value="on" class="{classes} style="{style}"" />', |
||||||
|
} |
||||||
|
|
||||||
|
def __init__(self, form_def): |
||||||
|
self.form_def = form_def |
||||||
|
|
||||||
|
def cast_params(self, params): |
||||||
|
new_params = params.copy() |
||||||
|
|
||||||
|
if 'required' in new_params: |
||||||
|
if new_params['required'] is False: |
||||||
|
new_params['required'] = "" |
||||||
|
else: |
||||||
|
new_params["required"] = "required" |
||||||
|
|
||||||
|
if 'classes' in new_params: |
||||||
|
new_params['classes'] = ' '.join(new_params['classes']) |
||||||
|
|
||||||
|
if 'checked' in new_params: |
||||||
|
if new_params['checked'] is False: |
||||||
|
new_params['checked'] = "" |
||||||
|
else: |
||||||
|
new_params['checked'] = "checked" |
||||||
|
|
||||||
|
return new_params |
||||||
|
|
||||||
|
def r_field(self, type, **kwargs): |
||||||
|
params = self.cast_params(kwargs) |
||||||
|
method_name = 'r_field_{0}'.format(type) |
||||||
|
method = getattr(self, method_name, None) |
||||||
|
return method(**params) |
||||||
|
|
||||||
|
def r_field_string(self, name, value, size=50, required=False, classes=[], style=""): |
||||||
|
tpl = self.field_tpl['string'] |
||||||
|
return tpl.format(name=name, value=value, size=size, required=required, classes=classes, style=style) |
||||||
|
|
||||||
|
def r_field_number(self, name, value, min=None, max=None, required=False, classes=[], style=""): |
||||||
|
tpl = self.field_tpl['number'] |
||||||
|
return tpl.format(name=name, value=value, min=min, max=max, required=required, classes=classes, style=style) |
||||||
|
|
||||||
|
def r_field_integer(self, name, value, min=None, max=None, required=False, classes=[], style=""): |
||||||
|
tpl = self.field_tpl['integer'] |
||||||
|
return tpl.format(name=name, value=value, min=min, max=max, required=required, classes=classes, style=style) |
||||||
|
|
||||||
|
def r_field_float(self, name, value, min=None, max=None, required=False, classes=[], style=""): |
||||||
|
tpl = self.field_tpl['integer'] |
||||||
|
return tpl.format(name=name, value=value, min=min, max=max, required=required, classes=classes, style=style) |
||||||
|
|
||||||
|
def r_field_date(self, name, value, required=False, classes=[], style=""): |
||||||
|
tpl = self.field_tpl['date'] |
||||||
|
return tpl.format(name=name, value=value, required=required, classes=classes, style=style) |
||||||
|
|
||||||
|
def r_field_file(self, name, required=False, classes=[], style=""): |
||||||
|
tpl = self.field_tpl['file'] |
||||||
|
return tpl.format(name=name, required=required, classes=classes, style=style) |
||||||
|
|
||||||
|
def r_field_password(self, name, value, min=None, required=False, classes=[], style=""): |
||||||
|
tpl = self.field_tpl['password'] |
||||||
|
return tpl.format(name=name, value=value, min=min, required=required, classes=classes, style=style) |
||||||
|
|
||||||
|
def r_field_text(self, name, value, rows=4, cols=80, required=False, classes=[], style=""): |
||||||
|
tpl = self.field_tpl['text'] |
||||||
|
return tpl.format(name=name, value=value, rows=rows, cols=cols, required=required, classes=classes, style=style) |
||||||
|
|
||||||
|
def r_field_radio(self, name, value, options, classes=[], style=""): |
||||||
|
tpl_option = self.field_tpl['radio_option'] |
||||||
|
radio_elems = [] |
||||||
|
for o_value, o_label in options: |
||||||
|
checked = '' |
||||||
|
if o_value == value: |
||||||
|
checked = 'checked' |
||||||
|
radio_elems.append(tpl_option.format(name=name, value=value, checked=checked, label=o_label, classes=classes, style=style)) |
||||||
|
return u''.join(radio_elems) |
||||||
|
|
||||||
|
def r_field_checkbox(self, name, checked, classes='', style=""): |
||||||
|
tpl = self.field_tpl['checkbox'] |
||||||
|
return tpl.format(name=name, checked=checked, classes=classes, style=style) |
||||||
|
|
||||||
|
def r_field_select(self, name, value, options, classes=[], style=""): |
||||||
|
tpl_option = self.field_tpl['select_option'] |
||||||
|
select_elems = [] |
||||||
|
for o_value, o_label in options: |
||||||
|
selected = '' |
||||||
|
if o_value == value: |
||||||
|
selected = 'selected' |
||||||
|
select_elems.append(tpl_option.format(value=o_value, selected=selected, label=o_label, style=style)) |
||||||
|
|
||||||
|
tpl = self.field_tpl['select'] |
||||||
|
return tpl.format(name=name, select_elems=''.join(select_elems), classes=classes, style=style) |
||||||
|
|
||||||
|
def r_form_line(self, type, title, input, classes, errors): |
||||||
|
if type == 'checkbox': |
||||||
|
html = html_field_checkbox |
||||||
|
else: |
||||||
|
html = html_field |
||||||
|
|
||||||
|
return (html.format(classes=' '.join(classes), |
||||||
|
title=title, |
||||||
|
input=input, |
||||||
|
errors=u', '.join(errors))) |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,500 @@ |
|||||||
|
from SocketServer import ThreadingMixIn |
||||||
|
import BaseHTTPServer |
||||||
|
from BaseHTTPServer import BaseHTTPRequestHandler |
||||||
|
import urlparse |
||||||
|
import cgi |
||||||
|
import logging |
||||||
|
import tempfile |
||||||
|
import os |
||||||
|
import base64 |
||||||
|
import hashlib |
||||||
|
|
||||||
|
from formrender import FormRender |
||||||
|
|
||||||
|
|
||||||
|
html_header = u'''<html> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<style> |
||||||
|
/* Default classes */ |
||||||
|
.btn {{ color: #FFFFFF; font-weight: bold; font-size: 0.9em; |
||||||
|
background-color: #1D98E4; padding: 9px; border-radius: 4px; |
||||||
|
border-width: 0px; text-decoration: none; }} |
||||||
|
.btn-act {{ background-color: #1D98E4; }} |
||||||
|
.btn-lnk {{ background-color: #D0D0D0; }} |
||||||
|
.error {{ color: #FF0000; }} |
||||||
|
|
||||||
|
/* Main element markup */ |
||||||
|
*,body {{ font-family: sans-serif; }} |
||||||
|
h1 {{ color: #555555; text-align: center; margin: 32px auto 32px auto; }} |
||||||
|
pre {{ font-family: monospace; }} |
||||||
|
|
||||||
|
/* List of available forms */ |
||||||
|
div.list {{ width: 50%; margin: 40px auto 0px auto; }} |
||||||
|
div.list li {{ font-size: 0.90em; list-style: none; |
||||||
|
margin-bottom: 65px; }} |
||||||
|
div.list h2 {{ background-color: #E0E5E5; |
||||||
|
border-radius: 3px; font-weight: bold; |
||||||
|
padding: 10px; font-size: 1.2em; }} |
||||||
|
div.list p.form-description {{ margin-left: 25px; }} |
||||||
|
div.list a.form-link {{ margin-left: 25px; }} |
||||||
|
|
||||||
|
/* Form display */ |
||||||
|
div.form {{ width: 50%; margin: 40px auto 0px auto; }} |
||||||
|
div.form h2 {{ font-weight: bold; background-color: #E0E5E5; padding: 25px; |
||||||
|
border-radius: 10px; }} |
||||||
|
div.form p.form-description {{ font-size: 0.90em; |
||||||
|
margin: 40px 25px 65px 25px; }} |
||||||
|
div.form li {{ font-size: 0.90em; list-style: none; }} |
||||||
|
div.form li.hidden {{ display: none; }} |
||||||
|
div.form p.form-field-title {{ margin-bottom: 0px; }} |
||||||
|
div.form p.form-field-input {{ margin-top: 0px; }} |
||||||
|
div.form li.checkbox p.form-field-input {{ float: left; margin-right: 8px; }} |
||||||
|
select, |
||||||
|
textarea, |
||||||
|
input[type=text], |
||||||
|
input[type=number], |
||||||
|
input[type=date], |
||||||
|
input[type=password] {{ color: #606060; padding: 9px; border-radius: 4px; |
||||||
|
border: 1px solid #D0D0D0; |
||||||
|
background-color: #F9F9F9; }} |
||||||
|
textarea {{ font-family: monospace; }} |
||||||
|
|
||||||
|
/* Result display */ |
||||||
|
div.result {{ width: 50%; margin: 40px auto 0px auto; }} |
||||||
|
div.result h2 {{ background-color: #E0E5E5; border-radius: 3px; |
||||||
|
font-weight: bold; padding: 10px; }} |
||||||
|
div.result div.result-result {{ margin-left: 25px; }} |
||||||
|
div.result ul.nav {{ margin: 64px 0px 128px 0px; padding-left: 0px; }} |
||||||
|
div.result ul.nav li {{ list-style: none; float: left; |
||||||
|
font-size: 0.90em; margin-right: 20px; }} |
||||||
|
|
||||||
|
/* Other */ |
||||||
|
div.about {{ text-align: center; font-size: 12px; color: #808080; }} |
||||||
|
div.about a {{ text-decoration: none; color: #000000; }} |
||||||
|
|
||||||
|
/* Custom css */ |
||||||
|
{custom_css} |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<h1>{title}</h1> |
||||||
|
<div class="page"> |
||||||
|
''' |
||||||
|
|
||||||
|
html_footer = u''' |
||||||
|
<div class="about">Powered by <a href="https://github.com/fboender/scriptform">Scriptform</a> v%%VERSION%%</div> |
||||||
|
</div> |
||||||
|
</body> |
||||||
|
</html> |
||||||
|
''' |
||||||
|
|
||||||
|
html_list = u'''' |
||||||
|
{header} |
||||||
|
<div class="list"> |
||||||
|
{form_list} |
||||||
|
</div> |
||||||
|
{footer} |
||||||
|
''' |
||||||
|
|
||||||
|
html_form = u''' |
||||||
|
{header} |
||||||
|
<div class="form"> |
||||||
|
<h2 class="form-title">{title}</h2> |
||||||
|
<p class="form-description">{description}</p> |
||||||
|
<form id="{name}" action="submit" method="post" enctype="multipart/form-data"> |
||||||
|
<input type="hidden" name="form_name" value="{name}" /> |
||||||
|
<ul> |
||||||
|
{fields} |
||||||
|
<li class="submit"> |
||||||
|
<input type="submit" class="btn btn-act" value="{submit_title}" /> |
||||||
|
<a href="."><button type="button" class="btn btn-lnk" value="Back">Back to the list</button></a> |
||||||
|
</li> |
||||||
|
</ul> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
{footer} |
||||||
|
''' |
||||||
|
|
||||||
|
html_submit_response = u''' |
||||||
|
{header} |
||||||
|
<div class="result"> |
||||||
|
<h2 class="result-title">{title}</h2> |
||||||
|
<h3 class="result-subtitle">Result</h3> |
||||||
|
<div class="result-result">{msg}</div> |
||||||
|
<ul class="nav"> |
||||||
|
<li> |
||||||
|
<a class="back-form btn btn-lnk" href="form?form_name={form_name}"> |
||||||
|
Back to the form |
||||||
|
</a> |
||||||
|
</li> |
||||||
|
<li><a class="btn btn-lnk" href=".">Back to the list</a></li> |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
{footer} |
||||||
|
''' |
||||||
|
|
||||||
|
|
||||||
|
class ThreadedHTTPServer(ThreadingMixIn, BaseHTTPServer.HTTPServer): |
||||||
|
pass |
||||||
|
|
||||||
|
|
||||||
|
class WebAppHandler(BaseHTTPRequestHandler): |
||||||
|
""" |
||||||
|
Basic web server request handler. Handles GET and POST requests. This class |
||||||
|
should be extended with methods (starting with 'h_') to handle the actual |
||||||
|
requests. If no path is set, it dispatches to the 'index' or 'default' |
||||||
|
method. |
||||||
|
""" |
||||||
|
def log_message(self, format, *args): |
||||||
|
"""Overrides BaseHTTPRequestHandler which logs to the console. We log |
||||||
|
to our log file instead""" |
||||||
|
self.scriptform.log.info("{} {}".format(self.address_string(), format%args)) |
||||||
|
|
||||||
|
def do_GET(self): |
||||||
|
self._call(*self._parse(self.path)) |
||||||
|
|
||||||
|
def do_POST(self): |
||||||
|
form_values = cgi.FieldStorage( |
||||||
|
fp=self.rfile, |
||||||
|
headers=self.headers, |
||||||
|
environ={'REQUEST_METHOD': 'POST'}) |
||||||
|
self._call(self.path.strip('/'), params={'form_values': form_values}) |
||||||
|
|
||||||
|
def _parse(self, reqinfo): |
||||||
|
url_comp = urlparse.urlsplit(reqinfo) |
||||||
|
path = url_comp.path |
||||||
|
qs = urlparse.parse_qs(url_comp.query) |
||||||
|
# Only return the first value of each query var. E.g. for |
||||||
|
# "?foo=1&foo=2" return '1'. |
||||||
|
vars = dict([(k, v[0]) for k, v in qs.items()]) |
||||||
|
return (path.strip('/'), vars) |
||||||
|
|
||||||
|
def _call(self, path, params): |
||||||
|
""" |
||||||
|
Find a method to call on self.app_class based on `path` and call it. |
||||||
|
The method that's called is in the form 'h_<PATH>'. If no path was |
||||||
|
given, it will try to call the 'index' method. If no method could be |
||||||
|
found but a `default` method exists, it is called. Otherwise 404 is |
||||||
|
sent. |
||||||
|
|
||||||
|
Methods should take care of sending proper headers and content |
||||||
|
themselves using self.send_response(), self.send_header(), |
||||||
|
self.end_header() and by writing to self.wfile. |
||||||
|
""" |
||||||
|
method_name = 'h_{0}'.format(path) |
||||||
|
method_cb = None |
||||||
|
try: |
||||||
|
if hasattr(self, method_name) and \ |
||||||
|
callable(getattr(self, method_name)): |
||||||
|
method_cb = getattr(self, method_name) |
||||||
|
elif path == '' and hasattr(self, 'index'): |
||||||
|
method_cb = self.index |
||||||
|
elif hasattr(self, 'default'): |
||||||
|
method_cb = self.default |
||||||
|
else: |
||||||
|
self.send_error(404, "Not found") |
||||||
|
return |
||||||
|
method_cb(**params) |
||||||
|
except Exception, e: |
||||||
|
self.scriptform.log.exception(e) |
||||||
|
self.send_error(500, "Internal server error") |
||||||
|
raise |
||||||
|
|
||||||
|
|
||||||
|
class ScriptFormWebApp(WebAppHandler): |
||||||
|
""" |
||||||
|
This class is a request handler for WebSrv. |
||||||
|
""" |
||||||
|
def index(self): |
||||||
|
""" |
||||||
|
Index handler. If there's only one form defined, render that form. |
||||||
|
Otherwise render a list of available forms. |
||||||
|
""" |
||||||
|
form_config = self.scriptform.get_form_config() |
||||||
|
|
||||||
|
visible_forms = form_config.get_visible_forms(getattr(self, 'username', None)) |
||||||
|
if len(visible_forms) == 1: |
||||||
|
first_form = visible_forms[0] |
||||||
|
return self.h_form(first_form.name) |
||||||
|
else: |
||||||
|
return self.h_list() |
||||||
|
|
||||||
|
def auth(self): |
||||||
|
""" |
||||||
|
Verify that the user is authenticated. This is required if the form |
||||||
|
definition contains a 'users' field. Returns True if the user is |
||||||
|
validated. Otherwise, returns False and sends 401 HTTP back to the |
||||||
|
client. |
||||||
|
""" |
||||||
|
form_config = self.scriptform.get_form_config() |
||||||
|
self.username = None |
||||||
|
|
||||||
|
# If a 'users' element was present in the form configuration file, the |
||||||
|
# user must be authenticated. |
||||||
|
if form_config.users: |
||||||
|
authorized = False |
||||||
|
auth_header = self.headers.getheader("Authorization") |
||||||
|
if auth_header is not None: |
||||||
|
auth_realm, auth_unpw = auth_header.split(' ', 1) |
||||||
|
username, password = base64.decodestring(auth_unpw).split(":") |
||||||
|
pw_hash = hashlib.sha256(password).hexdigest() |
||||||
|
# Validate the username and password |
||||||
|
if username in form_config.users and \ |
||||||
|
pw_hash == form_config.users[username]: |
||||||
|
self.username = username |
||||||
|
authorized = True |
||||||
|
|
||||||
|
if not authorized: |
||||||
|
# User is not authenticated. Send authentication request. |
||||||
|
self.send_response(401) |
||||||
|
self.send_header("WWW-Authenticate", 'Basic realm="Private Area"') |
||||||
|
self.end_headers() |
||||||
|
return False |
||||||
|
return True |
||||||
|
|
||||||
|
def h_list(self): |
||||||
|
""" |
||||||
|
Render a list of available forms. |
||||||
|
""" |
||||||
|
if not self.auth(): |
||||||
|
return |
||||||
|
|
||||||
|
form_config = self.scriptform.get_form_config() |
||||||
|
h_form_list = [] |
||||||
|
for form_def in form_config.get_visible_forms(getattr(self, 'username', None)): |
||||||
|
h_form_list.append(u''' |
||||||
|
<li> |
||||||
|
<h2 class="form-title">{title}</h2> |
||||||
|
<p class="form-description">{description}</p> |
||||||
|
<a class="form-link btn btn-act" href="./form?form_name={name}"> |
||||||
|
{title} |
||||||
|
</a> |
||||||
|
</li> |
||||||
|
'''.format(title=form_def.title, |
||||||
|
description=form_def.description, |
||||||
|
name=form_def.name) |
||||||
|
) |
||||||
|
|
||||||
|
output = html_list.format( |
||||||
|
header=html_header.format(title=form_config.title, |
||||||
|
custom_css=form_config.custom_css), |
||||||
|
footer=html_footer, |
||||||
|
form_list=u''.join(h_form_list) |
||||||
|
) |
||||||
|
self.send_response(200) |
||||||
|
self.send_header('Content-type', 'text/html') |
||||||
|
self.end_headers() |
||||||
|
self.wfile.write(output.encode('utf8')) |
||||||
|
|
||||||
|
def h_form(self, form_name, errors={}, **form_values): |
||||||
|
""" |
||||||
|
Render a form. |
||||||
|
""" |
||||||
|
if not self.auth(): |
||||||
|
return |
||||||
|
|
||||||
|
form_config = self.scriptform.get_form_config() |
||||||
|
fr = FormRender(None) |
||||||
|
|
||||||
|
def render_field(field, errors): |
||||||
|
params = { |
||||||
|
'name': field['name'], |
||||||
|
'classes': [], |
||||||
|
} |
||||||
|
|
||||||
|
if field.get('hidden', None): |
||||||
|
params['classes'].append('hidden') |
||||||
|
|
||||||
|
params["style"] = field.get("style", "") |
||||||
|
|
||||||
|
if field['type'] not in ('file', 'checkbox'): |
||||||
|
params['value'] = form_values.get(field['name'], '') |
||||||
|
|
||||||
|
if field['type'] not in ('radio', 'checkbox', 'select'): |
||||||
|
params['required'] = field.get('required', False) |
||||||
|
|
||||||
|
if field['type'] in ('string'): |
||||||
|
params['size'] = field.get('size', '') |
||||||
|
|
||||||
|
if field['type'] in ('number', 'integer', 'float', 'password'): |
||||||
|
params['min'] = field.get("min", '') |
||||||
|
|
||||||
|
if field['type'] in ('number', 'integer', 'float'): |
||||||
|
params['max'] = field.get("max", '') |
||||||
|
|
||||||
|
if field['type'] in ('text'): |
||||||
|
params['rows'] = field.get("rows", '') |
||||||
|
params['cols'] = field.get("cols", '') |
||||||
|
|
||||||
|
if field['type'] == 'radio': |
||||||
|
if not form_values.get(field['name'], None): |
||||||
|
params['value'] = field['options'][0][0] |
||||||
|
params['options'] = field['options'] |
||||||
|
|
||||||
|
if field['type'] in ('radio', 'select'): |
||||||
|
params['options'] = field['options'] |
||||||
|
|
||||||
|
if field['type'] == 'checkbox': |
||||||
|
params['checked'] = False |
||||||
|
if field['name'] in form_values and form_values[field['name']] == 'on': |
||||||
|
params['checked'] = True |
||||||
|
|
||||||
|
input = fr.r_field(field['type'], **params) |
||||||
|
|
||||||
|
return fr.r_form_line(field['type'], field['title'], |
||||||
|
input, params['classes'], errors) |
||||||
|
|
||||||
|
# Make sure the user is allowed to access this form. |
||||||
|
form_def = form_config.get_form_def(form_name) |
||||||
|
if form_def.allowed_users is not None and \ |
||||||
|
self.username not in form_def.allowed_users: |
||||||
|
self.send_error(401, "You're not authorized to view this form") |
||||||
|
return |
||||||
|
|
||||||
|
html_errors = u'' |
||||||
|
if errors: |
||||||
|
html_errors = u'<ul>' |
||||||
|
for error in errors: |
||||||
|
html_errors += u'<li class="error">{0}</li>'.format(error) |
||||||
|
html_errors += u'</ul>' |
||||||
|
|
||||||
|
output = html_form.format( |
||||||
|
header=html_header.format(title=form_config.title, |
||||||
|
custom_css=form_config.custom_css), |
||||||
|
footer=html_footer, |
||||||
|
title=form_def.title, |
||||||
|
description=form_def.description, |
||||||
|
errors=html_errors, |
||||||
|
name=form_def.name, |
||||||
|
fields=u''.join([render_field(f, errors.get(f['name'], [])) for f in form_def.fields]), |
||||||
|
submit_title=form_def.submit_title |
||||||
|
) |
||||||
|
self.send_response(200) |
||||||
|
self.send_header('Content-type', 'text/html') |
||||||
|
self.end_headers() |
||||||
|
self.wfile.write(output.encode('utf8')) |
||||||
|
|
||||||
|
def h_submit(self, form_values): |
||||||
|
""" |
||||||
|
Handle the submitting of a form by validating the values and then doing |
||||||
|
a callback to a script. How the output is |
||||||
|
handled depends on settings in the form definition. |
||||||
|
""" |
||||||
|
if not self.auth(): |
||||||
|
return |
||||||
|
|
||||||
|
form_config = self.scriptform.get_form_config() |
||||||
|
form_name = form_values.getfirst('form_name', None) |
||||||
|
form_def = form_config.get_form_def(form_name) |
||||||
|
if form_def.allowed_users is not None and \ |
||||||
|
self.username not in form_def.allowed_users: |
||||||
|
self.send_error(401, "You're not authorized to view this form") |
||||||
|
return |
||||||
|
|
||||||
|
# Convert FieldStorage to a simple dict, because we're not allowd to |
||||||
|
# add items to it. For normal fields, the form field name becomes the |
||||||
|
# key and the value becomes the field value. For file upload fields, we |
||||||
|
# stream the uploaded file to a temp file and then put the temp file in |
||||||
|
# the destination dict. We also add an extra field with the originally |
||||||
|
# uploaded file's name. |
||||||
|
values = {} |
||||||
|
tmp_files = [] |
||||||
|
for field_name in form_values: |
||||||
|
field = form_values[field_name] |
||||||
|
if field.filename is not None: |
||||||
|
# Field is an uploaded file. Stream it to a temp file if |
||||||
|
# something was actually uploaded |
||||||
|
if field.filename == '': |
||||||
|
continue |
||||||
|
tmpfile = tempfile.mktemp(prefix="scriptform_") |
||||||
|
f = file(tmpfile, 'w') |
||||||
|
while True: |
||||||
|
buf = field.file.read(1024 * 16) |
||||||
|
if not buf: |
||||||
|
break |
||||||
|
f.write(buf) |
||||||
|
f.close() |
||||||
|
field.file.close() |
||||||
|
|
||||||
|
tmp_files.append(tmpfile) # For later cleanup |
||||||
|
values[field_name] = tmpfile |
||||||
|
values['{0}__name'.format(field_name)] = field.filename |
||||||
|
else: |
||||||
|
# Field is a normal form field. Store its value. |
||||||
|
values[field_name] = form_values.getfirst(field_name, None) |
||||||
|
|
||||||
|
# Validate the form values |
||||||
|
form_errors, form_values = form_def.validate(values) |
||||||
|
|
||||||
|
if not form_errors: |
||||||
|
# Call user's callback. If a result is returned, we wrap its output |
||||||
|
# in some nice HTML. If no result is returned, the output was raw |
||||||
|
# and the callback should have written its own response to the |
||||||
|
# self.wfile filehandle. |
||||||
|
|
||||||
|
# Log the callback and its parameters for auditing purposes. |
||||||
|
log = logging.getLogger('CALLBACK_AUDIT') |
||||||
|
log.info("Calling script {0}".format(form_def.script)) |
||||||
|
log.info("User: {0}".format(getattr(self.request, 'username', 'None'))) |
||||||
|
log.info("Variables: {0}".format(dict(form_values.items()))) |
||||||
|
|
||||||
|
result = form_config.callback(form_name, form_values, self.wfile, self.wfile) |
||||||
|
if form_def.output != 'raw': |
||||||
|
# Ignore everything if we're doing raw output, since it's the |
||||||
|
# scripts responsibility. |
||||||
|
if result['exitcode'] != 0: |
||||||
|
msg = u'<span class="error">{0}</span>'.format(cgi.escape(result['stderr'].decode('utf8'))) |
||||||
|
else: |
||||||
|
if form_def.output == 'escaped': |
||||||
|
msg = u'<pre>{0}</pre>'.format(cgi.escape(result['stdout'].decode('utf8'))) |
||||||
|
else: |
||||||
|
# Non-escaped output (html, usually) |
||||||
|
msg = result['stdout'].decode('utf8') |
||||||
|
|
||||||
|
output = html_submit_response.format( |
||||||
|
header=html_header.format(title=form_config.title, |
||||||
|
custom_css=form_config.custom_css), |
||||||
|
footer=html_footer, |
||||||
|
title=form_def.title, |
||||||
|
form_name=form_def.name, |
||||||
|
msg=msg, |
||||||
|
) |
||||||
|
self.send_response(200) |
||||||
|
self.send_header('Content-type', 'text/html') |
||||||
|
self.end_headers() |
||||||
|
self.wfile.write(output.encode('utf8')) |
||||||
|
else: |
||||||
|
# Form had errors |
||||||
|
form_values.pop('form_name') |
||||||
|
self.h_form(form_name, form_errors, **form_values) |
||||||
|
|
||||||
|
# Clean up uploaded files |
||||||
|
for file_name in tmp_files: |
||||||
|
if os.path.exists(file_name): |
||||||
|
os.unlink(file_name) |
||||||
|
|
||||||
|
def h_static(self, fname): |
||||||
|
"""Serve static files""" |
||||||
|
if not self.auth(): |
||||||
|
return |
||||||
|
|
||||||
|
form_config = self.scriptform.get_form_config() |
||||||
|
|
||||||
|
if not form_config.static_dir: |
||||||
|
self.send_error(501, "Static file serving not enabled") |
||||||
|
return |
||||||
|
|
||||||
|
if '..' in fname: |
||||||
|
self.send_error(403, "Invalid file name") |
||||||
|
return |
||||||
|
|
||||||
|
path = os.path.join(form_config.static_dir, fname) |
||||||
|
if not os.path.exists(path): |
||||||
|
self.send_error(404, "Not found") |
||||||
|
return |
||||||
|
|
||||||
|
f = file(path, 'r') |
||||||
|
self.send_response(200) |
||||||
|
self.end_headers() |
||||||
|
self.wfile.write(f.read()) |
Loading…
Reference in new issue