diff --git a/pywebio/input.py b/pywebio/input.py index 6f22f214..a0c4b3a5 100644 --- a/pywebio/input.py +++ b/pywebio/input.py @@ -649,16 +649,7 @@ def file_upload(label: str = '', accept: Union[List, str] = None, name: str = No raise ValueError('The `max_size` and `max_total_size` value can not exceed the backend payload size limit. ' 'Please increase the `max_total_size` of `start_server()`/`path_deploy()`') - def read_file(data): - for file in data: - # Security fix: to avoid interpreting file name as path - file['filename'] = os.path.basename(file['filename']) - - if not multiple: - return data[0] if len(data) >= 1 else None - return data - - return single_input(item_spec, valid_func, read_file, onchange_func) + return single_input(item_spec, valid_func, lambda d: d, onchange_func) def slider(label: str = '', *, name: str = None, value: Union[int, float] = 0, min_value: Union[int, float] = 0, diff --git a/pywebio/pin.py b/pywebio/pin.py index 2f426936..5b52fd3b 100644 --- a/pywebio/pin.py +++ b/pywebio/pin.py @@ -70,7 +70,6 @@ Pin widgets ------------------ Each pin widget function corresponds to an input function of :doc:`input <./input>` module. -(For performance reasons, no pin widget for `file_upload() ` input function) The function of pin widget supports most of the parameters of the corresponding input function. Here lists the difference between the two in parameters: @@ -88,6 +87,7 @@ .. autofunction:: put_radio .. autofunction:: put_slider .. autofunction:: put_actions +.. autofunction:: put_file_upload Pin utils ------------------ @@ -137,7 +137,7 @@ _pin_name_chars = set(string.ascii_letters + string.digits + '_-') __all__ = ['put_input', 'put_textarea', 'put_select', 'put_checkbox', 'put_radio', 'put_slider', 'put_actions', - 'pin', 'pin_update', 'pin_wait_change', 'pin_on_change'] + 'put_file_upload', 'pin', 'pin_update', 'pin_wait_change', 'pin_on_change'] def _pin_output(single_input_return, scope, position): @@ -238,6 +238,17 @@ def put_actions(name: str, *, label: str = '', buttons: List[Union[Dict[str, Any return _pin_output(input_kwargs, scope, position) +def put_file_upload(name: str, *, label: str = '', accept: Union[List, str] = None, placeholder: str = 'Choose file', + multiple: bool = False, max_size: Union[int, str] = 0, max_total_size: Union[int, str] = 0, + help_text: str = None, scope: str = None, position: int = OutputPosition.BOTTOM) -> Output: + """Output a file uploading widget. Refer to: `pywebio.input.file_upload()`""" + from pywebio.input import file_upload + check_dom_name_value(name, 'pin `name`') + single_input_return = file_upload(label=label, accept=accept, name=name, placeholder=placeholder, multiple=multiple, + max_size=max_size, max_total_size=max_total_size, help_text=help_text) + return _pin_output(single_input_return, scope, position) + + @chose_impl def get_client_val(): res = yield next_client_event() @@ -365,7 +376,8 @@ def pin_on_change(name: str, onchange: Callable[[Any], None] = None, clear: bool """ assert not (onchange is None and clear is False), "When `onchange` is `None`, `clear` must be `True`" if onchange is not None: - callback_id = output_register_callback(onchange, **callback_options) + callback = lambda data: onchange(data['value']) + callback_id = output_register_callback(callback, **callback_options) if init_run: onchange(pin[name]) else: diff --git a/pywebio/platform/utils.py b/pywebio/platform/utils.py index 55f3acd7..bd3d51f4 100644 --- a/pywebio/platform/utils.py +++ b/pywebio/platform/utils.py @@ -1,5 +1,6 @@ import fnmatch import json +import os import socket import urllib.parse from collections import defaultdict @@ -54,16 +55,18 @@ def is_same_site(origin, host): def deserialize_binary_event(data: bytes): """ - Data format: + Binary event message is used to submit data with files upload to server. + + Data message format: | event | file_header | file_data | file_header | file_data | ... The 8 bytes at the beginning of each segment indicate the number of bytes remaining in the segment. event: { - event: "from_submit", - task_id: that.task_id, + ... data: { - input_name => input_data + input_name => input_data, + ... } } @@ -75,9 +78,18 @@ def deserialize_binary_event(data: bytes): 'input_name': name of input field } + file_data is the file content in bytes. + + - When a form field is not a file input, the `event['data'][input_name]` will be the value of the form field. + - When a form field is a single file, the `event['data'][input_name]` is None, + and there will only be one file_header+file_data at most. + - When a form field is a multiple files, the `event['data'][input_name]` is [], + and there may be multiple file_header+file_data. + Example: b'\x00\x00\x00\x00\x00\x00\x00E{"event":"from_submit","task_id":"main-4788341456","data":{"data":1}}\x00\x00\x00\x00\x00\x00\x00Y{"filename":"hello.txt","size":2,"mime_type":"text/plain","last_modified":1617119937.276}\x00\x00\x00\x00\x00\x00\x00\x02ss' """ + # split data into segments parts = [] start_idx = 0 while start_idx < len(data): @@ -88,17 +100,26 @@ def deserialize_binary_event(data: bytes): start_idx += size event = json.loads(parts[0]) + + # deserialize file data files = defaultdict(list) for idx in range(1, len(parts), 2): f = json.loads(parts[idx]) f['content'] = parts[idx + 1] + + # Security fix: to avoid interpreting file name as path + f['filename'] = os.path.basename(f['filename']) + input_name = f.pop('input_name') files[input_name].append(f) + # fill file data to event for input_name in list(event['data'].keys()): if input_name in files: + init = event['data'][input_name] event['data'][input_name] = files[input_name] - + if init is None: # the file is not multiple + event['data'][input_name] = files[input_name][0] if len(files[input_name]) else None return event diff --git a/webiojs/src/handlers/input.ts b/webiojs/src/handlers/input.ts index 9441463c..52d7bbe0 100644 --- a/webiojs/src/handlers/input.ts +++ b/webiojs/src/handlers/input.ts @@ -1,5 +1,5 @@ import {Command, Session} from "../session"; -import {error_alert, LRUMap, make_set, serialize_json} from "../utils"; +import {error_alert, LRUMap, make_set, serialize_json, serialize_file} from "../utils"; import {InputItem} from "../models/input/base" import {state} from '../state' import {all_input_items} from "../models/input" @@ -234,11 +234,11 @@ class FormController { } - let data_keys: string[] = []; - let data_values: any[] = []; + let input_names: string[] = []; + let input_values: any[] = []; $.each(that.name2input, (name, ctrl) => { - data_keys.push(name as string); - data_values.push(ctrl.get_value()); + input_names.push(name as string); + input_values.push(ctrl.get_value()); }); let on_process = (loaded: number, total: number) => { @@ -250,14 +250,17 @@ class FormController { break; } } - Promise.all(data_values).then((values) => { + Promise.all(input_values).then((values) => { let input_data: { [i: string]: any } = {}; let files: Blob[] = []; - for (let idx in data_keys) { - input_data[data_keys[idx]] = values[idx]; + for (let idx in input_names) { + let name = input_names[idx], value = values[idx]; + input_data[name] = value; if (that.spec.inputs[idx].type == 'file') { - input_data[data_keys[idx]] = []; - files.push(...values[idx]); + input_data[name] = value.multiple ? [] : null; + value.files.forEach((file: File) => { + files.push(serialize_file(file, name)) + }); } } let msg = { @@ -266,6 +269,7 @@ class FormController { data: input_data }; if (files.length) { + // see also: `py:pywebio.platform.utils.deserialize_binary_event()` that.session.send_buffer(new Blob([serialize_json(msg), ...files], {type: 'application/octet-stream'}), on_process); } else { that.session.send_message(msg, on_process); diff --git a/webiojs/src/handlers/pin.ts b/webiojs/src/handlers/pin.ts index f320d4ad..4de058b9 100644 --- a/webiojs/src/handlers/pin.ts +++ b/webiojs/src/handlers/pin.ts @@ -1,7 +1,8 @@ -import {Command, Session} from "../session"; +import {ClientEvent, Command, Session} from "../session"; import {CommandHandler} from "./base"; -import {GetPinValue, PinChangeCallback, PinUpdate, WaitChange} from "../models/pin"; +import {GetPinValue, PinChangeCallback, PinUpdate, WaitChange, IsFileInput} from "../models/pin"; import {state} from "../state"; +import {serialize_file, serialize_json} from "../utils"; export class PinHandler implements CommandHandler { @@ -15,22 +16,56 @@ export class PinHandler implements CommandHandler { handle_message(msg: Command) { if (msg.command === 'pin_value') { - let val = GetPinValue(msg.spec.name); - let data = val===undefined? null : {value: val}; - state.CurrentSession.send_message({event: "js_yield", task_id: msg.task_id, data: data}); + let val = GetPinValue(msg.spec.name); // undefined or value + let send_msg = { + event: "js_yield", task_id: msg.task_id, + data: val === undefined ? null : {value: val} + }; + this.submit(send_msg, IsFileInput(msg.spec.name)); } else if (msg.command === 'pin_update') { PinUpdate(msg.spec.name, msg.spec.attributes); } else if (msg.command === 'pin_wait') { let p = WaitChange(msg.spec.names, msg.spec.timeout); - Promise.resolve(p).then(function (value) { - state.CurrentSession.send_message({event: "js_yield", task_id: msg.task_id, data: value}); + Promise.resolve(p).then((change_info: (null | { name: string, value: any })) => { + // change_info: null or {'name': name, 'value': value} + let send_msg = {event: "js_yield", task_id: msg.task_id, data: change_info} + this.submit(send_msg, IsFileInput(change_info.name)); }).catch((error) => { console.error('error in `pin_wait`: %s', error); - state.CurrentSession.send_message({event: "js_yield", task_id: msg.task_id, data: null}); + this.submit({event: "js_yield", task_id: msg.task_id, data: null}); }); - }else if (msg.command === 'pin_onchange') { - PinChangeCallback(msg.spec.name, msg.spec.callback_id, msg.spec.clear); + } else if (msg.command === 'pin_onchange') { + let onchange = (val: any) => { + let send_msg = { + event: "callback", + task_id: msg.spec.callback_id, + data: {value: val} + } + this.submit(send_msg, IsFileInput(msg.spec.name)); + } + PinChangeCallback(msg.spec.name, msg.spec.callback_id ? onchange : null, msg.spec.clear); } + } + /* + * Send pin value to server. + * `msg.data` may be null, or {value: any, ...} + * `msg.data.value` stores the value of the pin. + * when submit files, `msg.data.value` is {multiple: bool, files: File[] } + * */ + submit(msg: ClientEvent, is_file: boolean = false) { + if (is_file && msg.data !== null) { + // msg.data.value: {multiple: bool, files: File[]} + let {multiple, files} = msg.data.value; + msg.data.value = multiple ? [] : null; // replace file value with initial value + state.CurrentSession.send_buffer( + new Blob([ + serialize_json(msg), + ...files.map((file: File) => serialize_file(file, 'value')) + ], {type: 'application/octet-stream'}) + ); + } else { + state.CurrentSession.send_message(msg); + } } } \ No newline at end of file diff --git a/webiojs/src/models/input/file.ts b/webiojs/src/models/input/file.ts index 76d7173c..661aaf7e 100644 --- a/webiojs/src/models/input/file.ts +++ b/webiojs/src/models/input/file.ts @@ -1,5 +1,5 @@ import {InputItem} from "./base"; -import {deep_copy, serialize_file} from "../../utils"; +import {deep_copy} from "../../utils"; import {t} from "../../i18n"; const file_input_tpl = ` @@ -14,10 +14,10 @@ const file_input_tpl = ` `; -export class File extends InputItem { +export class FileUpload extends InputItem { static accept_input_types: string[] = ["file"]; - files: Blob[] = []; // Files to be uploaded + files: File[] = []; // Files to be uploaded valid = true; constructor(spec: any, task_id: string, on_input_event: (event_name: string, input_item: InputItem) => void) { @@ -72,10 +72,12 @@ export class File extends InputItem { if (!that.valid) return; that.update_input_helper(-1, {'valid_status': 0}); - that.files.push(serialize_file(f, spec.name)); - + that.files.push(f); } + if (spec.onchange) { + that.on_input_event("change", that); + } }); return this.element; @@ -100,7 +102,10 @@ export class File extends InputItem { } get_value(): any { - return this.files; + return { + multiple: this.spec.multiple, + files: this.files + } } after_add_to_dom(): any { diff --git a/webiojs/src/models/input/index.ts b/webiojs/src/models/input/index.ts index a26ffebc..b41b0071 100644 --- a/webiojs/src/models/input/index.ts +++ b/webiojs/src/models/input/index.ts @@ -2,13 +2,13 @@ import {Input} from "./input" import {Actions} from "./actions" import {CheckboxRadio} from "./checkbox_radio" import {Textarea} from "./textarea" -import {File} from "./file" +import {FileUpload} from "./file" import {Select} from "./select" import {Slider} from "./slider" import {InputItem} from "./base"; -export const all_input_items = [Input, Actions, CheckboxRadio, Textarea, File, Select, Slider]; +export const all_input_items = [Input, Actions, CheckboxRadio, Textarea, FileUpload, Select, Slider]; export function get_input_item_from_type(type: string) { return type2item[type]; diff --git a/webiojs/src/models/pin.ts b/webiojs/src/models/pin.ts index b54a160e..17aa5474 100644 --- a/webiojs/src/models/pin.ts +++ b/webiojs/src/models/pin.ts @@ -8,6 +8,10 @@ import {pushData} from "../session"; let name2input: { [k: string]: InputItem } = {}; +export function IsFileInput(name: string): boolean { + return name2input[name] !== undefined && name2input[name].spec.type == "file"; +} + export function GetPinValue(name: string) { if (name2input[name] == undefined || !document.contains(name2input[name].element[0])) return undefined; @@ -47,14 +51,12 @@ export function WaitChange(names: string[], timeout: number) { }); } -export function PinChangeCallback(name: string, callback_id: string, clear: boolean) { +export function PinChangeCallback(name: string, callback: null | ((val: any) => void), clear: boolean) { if (!(name in resident_onchange_callbacks) || clear) resident_onchange_callbacks[name] = []; - if (callback_id) { - resident_onchange_callbacks[name].push((val) => { - pushData(val, callback_id) - }) + if (callback) { + resident_onchange_callbacks[name].push(callback) } }