001/*
002 * Copyright 2004 The Apache Software Foundation.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Anahide Tchertchian
018 *     Florent Guillaume
019 */
020package org.nuxeo.ecm.platform.ui.web.component.file;
021
022import java.io.IOException;
023import java.util.ArrayList;
024import java.util.HashMap;
025import java.util.List;
026import java.util.Map;
027
028import javax.el.ELException;
029import javax.el.ValueExpression;
030import javax.faces.FacesException;
031import javax.faces.application.Application;
032import javax.faces.application.FacesMessage;
033import javax.faces.component.EditableValueHolder;
034import javax.faces.component.NamingContainer;
035import javax.faces.component.UIComponent;
036import javax.faces.component.UIInput;
037import javax.faces.component.html.HtmlInputText;
038import javax.faces.context.FacesContext;
039import javax.faces.context.ResponseWriter;
040import javax.faces.convert.ConverterException;
041import javax.faces.event.ValueChangeEvent;
042import javax.faces.validator.ValidatorException;
043
044import org.apache.commons.lang3.StringUtils;
045import org.apache.commons.logging.Log;
046import org.apache.commons.logging.LogFactory;
047import org.nuxeo.ecm.core.api.Blob;
048import org.nuxeo.ecm.core.blob.BlobManager;
049import org.nuxeo.ecm.core.blob.BlobProvider;
050import org.nuxeo.ecm.platform.ui.web.application.NuxeoResponseStateManagerImpl;
051import org.nuxeo.ecm.platform.ui.web.util.ComponentUtils;
052import org.nuxeo.runtime.api.Framework;
053
054import com.sun.faces.util.MessageFactory;
055
056/**
057 * UIInput file that handles complex validation.
058 * <p>
059 * Attribute value is the file to be uploaded. Its submitted value as well as filename are handled by sub components.
060 * Rendering and validation of subcomponents are handled here.
061 */
062public class UIInputFile extends UIInput implements NamingContainer {
063
064    public static final String COMPONENT_TYPE = UIInputFile.class.getName();
065
066    public static final String COMPONENT_FAMILY = "javax.faces.Input";
067
068    protected static final String CHOICE_FACET_NAME = "choice";
069
070    protected static final String UPLOAD_FACET_NAME = "upload";
071
072    protected static final String DEFAULT_DOWNLOAD_FACET_NAME = "default_download";
073
074    protected static final String DOWNLOAD_FACET_NAME = "download";
075
076    protected static final String EDIT_FILENAME_FACET_NAME = "edit_filename";
077
078    protected static final Log log = LogFactory.getLog(UIInputFile.class);
079
080    protected final JSFBlobUploaderService uploaderService;
081
082    // value for filename, will disappear when it's part of the blob
083    protected String filename;
084
085    // used to decide whether filename can be edited
086    protected Boolean editFilename;
087
088    protected String onchange;
089
090    protected String onclick;
091
092    protected String onselect;
093
094    public UIInputFile() {
095        // initiate sub components
096        FacesContext faces = FacesContext.getCurrentInstance();
097        Application app = faces.getApplication();
098        ComponentUtils.initiateSubComponent(this, DEFAULT_DOWNLOAD_FACET_NAME,
099                app.createComponent(UIOutputFile.COMPONENT_TYPE));
100        ComponentUtils.initiateSubComponent(this, EDIT_FILENAME_FACET_NAME,
101                app.createComponent(HtmlInputText.COMPONENT_TYPE));
102        uploaderService = Framework.getService(JSFBlobUploaderService.class);
103        for (JSFBlobUploader uploader : uploaderService.getJSFBlobUploaders()) {
104            uploader.hookSubComponent(this);
105        }
106    }
107
108    // component will render itself
109    @Override
110    public String getRendererType() {
111        return null;
112    }
113
114    // getters and setters
115
116    /**
117     * Override value so that an {@link InputFileInfo} structure is given instead of the "value" attribute resolution.
118     */
119    @Override
120    public Object getValue() {
121        Object localValue = getLocalValue();
122        if (localValue != null) {
123            return localValue;
124        } else {
125            Blob blob = null;
126            Object originalValue = super.getValue();
127            String mimeType = null;
128            if (originalValue instanceof Blob) {
129                blob = (Blob) originalValue;
130                mimeType = blob.getMimeType();
131            }
132            List<String> choices = getAvailableChoices(blob, false);
133            String choice = choices.get(0);
134            return new InputFileInfo(choice, blob, getFilename(), mimeType);
135        }
136    }
137
138    public String getFilename() {
139        if (filename != null) {
140            return filename;
141        }
142        ValueExpression ve = getValueExpression("filename");
143        if (ve != null) {
144            try {
145                return (String) ve.getValue(getFacesContext().getELContext());
146            } catch (ELException e) {
147                throw new FacesException(e);
148            }
149        } else {
150            return null;
151        }
152    }
153
154    public void setFilename(String filename) {
155        this.filename = filename;
156    }
157
158    public Boolean getEditFilename() {
159        if (editFilename != null) {
160            return editFilename;
161        }
162        ValueExpression ve = getValueExpression("editFilename");
163        if (ve != null) {
164            try {
165                return !Boolean.FALSE.equals(ve.getValue(getFacesContext().getELContext()));
166            } catch (ELException e) {
167                throw new FacesException(e);
168            }
169        } else {
170            // default value
171            return false;
172        }
173    }
174
175    public void setEditFilename(Boolean editFilename) {
176        this.editFilename = editFilename;
177    }
178
179    public InputFileInfo getFileInfoValue() {
180        return (InputFileInfo) getValue();
181    }
182
183    public InputFileInfo getFileInfoLocalValue() {
184        return (InputFileInfo) getLocalValue();
185    }
186
187    public InputFileInfo getFileInfoSubmittedValue() {
188        return (InputFileInfo) getSubmittedValue();
189    }
190
191    protected String getStringValue(String name, String defaultValue) {
192        ValueExpression ve = getValueExpression(name);
193        if (ve != null) {
194            try {
195                return (String) ve.getValue(getFacesContext().getELContext());
196            } catch (ELException e) {
197                throw new FacesException(e);
198            }
199        } else {
200            return defaultValue;
201        }
202    }
203
204    public String getOnchange() {
205        if (onchange != null) {
206            return onchange;
207        }
208        return getStringValue("onchange", null);
209    }
210
211    public void setOnchange(String onchange) {
212        this.onchange = onchange;
213    }
214
215    public String getOnclick() {
216        if (onclick != null) {
217            return onclick;
218        }
219        return getStringValue("onclick", null);
220    }
221
222    public void setOnclick(String onclick) {
223        this.onclick = onclick;
224    }
225
226    public String getOnselect() {
227        if (onselect != null) {
228            return onselect;
229        }
230        return getStringValue("onselect", null);
231    }
232
233    public void setOnselect(String onselect) {
234        this.onselect = onselect;
235    }
236
237    // handle submitted values
238
239    @Override
240    public void decode(FacesContext context) {
241        if (context == null) {
242            throw new IllegalArgumentException();
243        }
244
245        // Force validity back to "true"
246        setValid(true);
247
248        // decode the radio button, other input components will decode
249        // themselves
250        Map<String, String> requestMap = context.getExternalContext().getRequestParameterMap();
251        String radioClientId = getClientId(context) + NamingContainer.SEPARATOR_CHAR + CHOICE_FACET_NAME;
252        String choice = requestMap.get(radioClientId);
253        // other submitted values will be handled at validation time
254        InputFileInfo submitted = new InputFileInfo(choice, null, null, null);
255        setSubmittedValue(submitted);
256    }
257
258    /**
259     * Process validation. Sub components are already validated.
260     */
261    @Override
262    public void validate(FacesContext context) {
263        if (context == null) {
264            throw new IllegalArgumentException();
265        }
266
267        // Submitted value == null means "the component was not submitted
268        // at all"; validation should not continue
269        InputFileInfo submitted = getFileInfoSubmittedValue();
270        if (submitted == null) {
271            return;
272        }
273
274        InputFileInfo previous = getFileInfoValue();
275
276        // validate choice
277        String choice;
278        try {
279            choice = submitted.getConvertedChoice();
280        } catch (ConverterException ce) {
281            ComponentUtils.addErrorMessage(context, this, ce.getMessage());
282            setValid(false);
283            return;
284        }
285        if (choice == null) {
286            ComponentUtils.addErrorMessage(context, this, "error.inputFile.choiceRequired");
287            setValid(false);
288            return;
289        }
290        submitted.setChoice(choice);
291        String previousChoice = previous.getConvertedChoice();
292        boolean temp = InputFileChoice.isUploadOrKeepTemp(previousChoice);
293        List<String> choices = getAvailableChoices(previous.getBlob(), temp);
294        if (!choices.contains(choice)) {
295            ComponentUtils.addErrorMessage(context, this, "error.inputFile.invalidChoice");
296            setValid(false);
297            return;
298        }
299
300        // validate choice in respect to other submitted values
301        if (InputFileChoice.KEEP_TEMP.equals(choice) || InputFileChoice.KEEP.equals(choice)) {
302            if (isLocalValueSet() || InputFileChoice.KEEP.equals(choice)) {
303                // re-submit stored values
304                submitted.setInfo(previous);
305            }
306            if (getEditFilename()) {
307                validateFilename(context, submitted);
308            }
309        } else if (InputFileChoice.isUpload(choice)) {
310            try {
311                uploaderService.getJSFBlobUploader(choice).validateUpload(this, context, submitted);
312                if (isValid()) {
313                    submitted.setChoice(InputFileChoice.KEEP_TEMP);
314                } else {
315                    // re-submit stored values
316                    submitted.setInfo(previous);
317                }
318            } catch (ValidatorException e) {
319                // re-submit stored values
320                submitted.setInfo(previous);
321            }
322        } else if (InputFileChoice.DELETE.equals(choice) || InputFileChoice.NONE.equals(choice)) {
323            submitted.setInfo(null);
324        }
325
326        // will need this to call declared validators
327        super.validateValue(context, submitted);
328
329        // If our value is valid, store the new value, erase the
330        // "submitted" value, and emit a ValueChangeEvent if appropriate
331        if (isValid()) {
332            setValue(submitted);
333            setSubmittedValue(null);
334            if (compareValues(previous, submitted)) {
335                queueEvent(new ValueChangeEvent(this, previous, submitted));
336            }
337        }
338    }
339
340    public void validateFilename(FacesContext context, InputFileInfo submitted) {
341        // validate filename
342        UIComponent filenameFacet = getFacet(EDIT_FILENAME_FACET_NAME);
343        if (filenameFacet instanceof EditableValueHolder) {
344            EditableValueHolder filenameComp = (EditableValueHolder) filenameFacet;
345            submitted.setFilename(filenameComp.getLocalValue());
346            String filename;
347            try {
348                filename = submitted.getConvertedFilename();
349            } catch (ConverterException ce) {
350                ComponentUtils.addErrorMessage(context, this, ce.getMessage());
351                setValid(false);
352                return;
353            }
354            submitted.setFilename(filename);
355        }
356    }
357
358    public void updateFilename(FacesContext context, String newFilename) {
359        // set filename by hand after validation
360        ValueExpression ve = getValueExpression("filename");
361        if (ve != null) {
362            ve.setValue(context.getELContext(), newFilename);
363        }
364    }
365
366    @Override
367    public void updateModel(FacesContext context) {
368        if (context == null) {
369            throw new IllegalArgumentException();
370        }
371
372        if (!isValid() || !isLocalValueSet()) {
373            return;
374        }
375        ValueExpression ve = getValueExpression("value");
376        if (ve == null) {
377            return;
378        }
379        try {
380            InputFileInfo local = getFileInfoLocalValue();
381            String choice = local.getConvertedChoice();
382            // set blob and filename
383            if (InputFileChoice.DELETE.equals(choice)) {
384                // set filename first to avoid error in case it maps the blob filename
385                ValueExpression vef = getValueExpression("filename");
386                if (vef != null) {
387                    vef.setValue(context.getELContext(), local.getConvertedFilename());
388                }
389                ve.setValue(context.getELContext(), local.getConvertedBlob());
390                setValue(null);
391                setLocalValueSet(false);
392            } else if (InputFileChoice.isUploadOrKeepTemp(choice)) {
393                // set blob first to avoid error in case the filename maps the blob filename
394                ve.setValue(context.getELContext(), local.getConvertedBlob());
395                setValue(null);
396                setLocalValueSet(false);
397                ValueExpression vef = getValueExpression("filename");
398                if (vef != null) {
399                    vef.setValue(context.getELContext(), local.getConvertedFilename());
400                }
401            } else if (InputFileChoice.KEEP.equals(choice)) {
402                // reset local value
403                setValue(null);
404                setLocalValueSet(false);
405                if (getEditFilename()) {
406                    // set filename
407                    ValueExpression vef = getValueExpression("filename");
408                    if (vef != null) {
409                        vef.setValue(context.getELContext(), local.getConvertedFilename());
410                    }
411                }
412            }
413            return;
414        } catch (ELException e) {
415            String messageStr = e.getMessage();
416            Throwable result = e.getCause();
417            while (result != null && result instanceof ELException) {
418                messageStr = result.getMessage();
419                result = result.getCause();
420            }
421            FacesMessage message;
422            if (messageStr == null) {
423                message = MessageFactory.getMessage(context, UPDATE_MESSAGE_ID, MessageFactory.getLabel(context, this));
424            } else {
425                message = new FacesMessage(FacesMessage.SEVERITY_ERROR, messageStr, messageStr);
426            }
427            context.addMessage(getClientId(context), message);
428            setValid(false);
429        } catch (IllegalArgumentException | ConverterException e) {
430            FacesMessage message = MessageFactory.getMessage(context, UPDATE_MESSAGE_ID,
431                    MessageFactory.getLabel(context, this));
432            context.addMessage(getClientId(context), message);
433            setValid(false);
434        }
435    }
436
437    // rendering methods
438
439    protected List<String> getAvailableChoices(Blob blob, boolean temp) {
440        List<String> choices = new ArrayList<String>(3);
441        boolean isRequired = isRequired();
442        if (blob != null) {
443            choices.add(temp ? InputFileChoice.KEEP_TEMP : InputFileChoice.KEEP);
444        } else if (!isRequired) {
445            choices.add(InputFileChoice.NONE);
446        }
447        boolean allowUpdate = true;
448        if (blob != null) {
449            BlobManager blobManager = Framework.getService(BlobManager.class);
450            BlobProvider blobProvider = blobManager.getBlobProvider(blob);
451            if (blobProvider != null && !blobProvider.supportsUserUpdate()) {
452                allowUpdate = false;
453            }
454        }
455        if (allowUpdate) {
456            for (JSFBlobUploader uploader : uploaderService.getJSFBlobUploaders()) {
457                choices.add(uploader.getChoice());
458            }
459            if (blob != null && !isRequired) {
460                choices.add(InputFileChoice.DELETE);
461            }
462        }
463        return choices;
464    }
465
466    public Blob getCurrentBlob() {
467        Blob blob = null;
468        InputFileInfo submittedFileInfo = getFileInfoSubmittedValue();
469        if (submittedFileInfo != null) {
470            String choice = submittedFileInfo.getConvertedChoice();
471            if (InputFileChoice.isKeepOrKeepTemp(choice)) {
472                // rebuild other info from current value
473                InputFileInfo fileInfo = getFileInfoValue();
474                blob = fileInfo.getConvertedBlob();
475            } else {
476                blob = submittedFileInfo.getConvertedBlob();
477            }
478        } else {
479            InputFileInfo fileInfo = getFileInfoValue();
480            blob = fileInfo.getConvertedBlob();
481        }
482        return blob;
483    }
484
485    public String getCurrentFilename() {
486        String filename = null;
487        InputFileInfo submittedFileInfo = getFileInfoSubmittedValue();
488        if (submittedFileInfo != null) {
489            String choice = submittedFileInfo.getConvertedChoice();
490            if (InputFileChoice.isKeepOrKeepTemp(choice)) {
491                // rebuild it in case it's supposed to be kept
492                InputFileInfo fileInfo = getFileInfoValue();
493                filename = fileInfo.getConvertedFilename();
494            } else {
495                filename = submittedFileInfo.getConvertedFilename();
496            }
497        } else {
498            InputFileInfo fileInfo = getFileInfoValue();
499            filename = fileInfo.getConvertedFilename();
500        }
501        return filename;
502    }
503
504    @Override
505    public void encodeBegin(FacesContext context) throws IOException {
506
507        notifyPreviousErrors(context);
508
509        // not ours to close
510        ResponseWriter writer = context.getResponseWriter();
511        Blob blob = null;
512        try {
513            blob = getCurrentBlob();
514        } catch (ConverterException e) {
515            // can happen -> ignore, don't break rendering
516        }
517        String filename = null;
518        try {
519            filename = getCurrentFilename();
520        } catch (ConverterException e) {
521            // can happen -> ignore, don't break rendering
522        }
523        InputFileInfo fileInfo = getFileInfoSubmittedValue();
524        if (fileInfo == null) {
525            fileInfo = getFileInfoValue();
526        }
527        String currentChoice = fileInfo.getConvertedChoice();
528        boolean temp = InputFileChoice.KEEP_TEMP.equals(currentChoice);
529        List<String> choices = getAvailableChoices(blob, temp);
530
531        String radioClientId = getClientId(context) + NamingContainer.SEPARATOR_CHAR + CHOICE_FACET_NAME;
532        writer.startElement("table", this);
533        writer.writeAttribute("class", "dataInput", null);
534        writer.startElement("tbody", this);
535        writer.writeAttribute("class", getAttributes().get("styleClass"), null);
536        for (String radioChoice : choices) {
537            String id = radioClientId + radioChoice;
538            writer.startElement("tr", this);
539            writer.startElement("td", this);
540            writer.writeAttribute("class", "radioColumn", null);
541            Map<String, String> props = new HashMap<String, String>();
542            props.put("type", "radio");
543            props.put("name", radioClientId);
544            props.put("id", id);
545            props.put("value", radioChoice);
546            if (radioChoice.equals(currentChoice)) {
547                props.put("checked", "checked");
548            }
549            String onchange = getOnchange();
550            if (onchange != null) {
551                props.put("onchange", onchange);
552            }
553            String onclick = getOnclick();
554            if (onclick != null) {
555                props.put("onclick", onclick);
556            }
557            String onselect = getOnselect();
558            if (onselect != null) {
559                props.put("onselect", onselect);
560            }
561            StringBuffer htmlBuffer = new StringBuffer();
562            htmlBuffer.append("<input");
563            for (Map.Entry<String, String> prop : props.entrySet()) {
564                htmlBuffer.append(" " + prop.getKey() + "=\"" + prop.getValue() + "\"");
565            }
566            htmlBuffer.append(" />");
567            writer.write(htmlBuffer.toString());
568            writer.endElement("td");
569            writer.startElement("td", this);
570            writer.writeAttribute("class", "fieldColumn", null);
571            String label = (String) ComponentUtils.getAttributeValue(this, radioChoice + "Label", null);
572            if (label == null) {
573                label = ComponentUtils.translate(context, "label.inputFile." + radioChoice + "Choice");
574            }
575            writer.write("<label for=\"" + id + "\" style=\"float:left\">" + label + "</label>");
576            writer.write(ComponentUtils.WHITE_SPACE_CHARACTER);
577            if (InputFileChoice.isKeepOrKeepTemp(radioChoice)) {
578                UIComponent downloadFacet = getFacet(DOWNLOAD_FACET_NAME);
579                if (downloadFacet != null) {
580                    // redefined in template
581                    ComponentUtils.encodeComponent(context, downloadFacet);
582                } else {
583                    downloadFacet = getFacet(DEFAULT_DOWNLOAD_FACET_NAME);
584                    if (downloadFacet != null) {
585                        UIOutputFile downloadComp = (UIOutputFile) downloadFacet;
586                        downloadComp.setQueryParent(true);
587                        ComponentUtils.copyValues(this, downloadComp, new String[] { "downloadLabel", "iconRendered" });
588                        ComponentUtils.copyLinkValues(this, downloadComp);
589                        ComponentUtils.encodeComponent(context, downloadComp);
590                    }
591                }
592                if (getEditFilename()) {
593                    writer.write("<br />");
594                    UIComponent filenameFacet = getFacet(EDIT_FILENAME_FACET_NAME);
595                    if (filenameFacet instanceof HtmlInputText) {
596                        HtmlInputText filenameComp = (HtmlInputText) filenameFacet;
597                        filenameComp.setValue(filename);
598                        filenameComp.setLocalValueSet(false);
599                        String onClick = "document.getElementById('%s').checked='checked'";
600                        filenameComp.setOnclick(String.format(onClick, id));
601                        writer.write(ComponentUtils.WHITE_SPACE_CHARACTER);
602                        label = (String) ComponentUtils.getAttributeValue(this, "editFilenameLabel", null);
603                        if (label == null) {
604                            label = ComponentUtils.translate(context, "label.inputFile.editFilename");
605                        }
606                        writer.write("<label for=\"" + filenameComp.getId() + "\">" + label + "</label>");
607                        writer.write(ComponentUtils.WHITE_SPACE_CHARACTER);
608                        ComponentUtils.encodeComponent(context, filenameComp);
609                    }
610                }
611            } else if (InputFileChoice.isUpload(radioChoice)) {
612                String onChange = String.format("document.getElementById('%s').checked='checked'", id);
613                uploaderService.getJSFBlobUploader(radioChoice).encodeBeginUpload(this, context, onChange);
614            }
615            writer.endElement("td");
616            writer.endElement("tr");
617        }
618        writer.endElement("tbody");
619        writer.endElement("table");
620        writer.flush();
621    }
622
623    /**
624     * @since 6.1.1
625     */
626    private void notifyPreviousErrors(FacesContext context) {
627        final Object hasError = context.getAttributes().get(NuxeoResponseStateManagerImpl.MULTIPART_SIZE_ERROR_FLAG);
628        final String componentId = (String) context.getAttributes().get(
629                NuxeoResponseStateManagerImpl.MULTIPART_SIZE_ERROR_COMPONENT_ID);
630        if (Boolean.TRUE.equals(hasError)) {
631            if (StringUtils.isBlank(componentId)) {
632                ComponentUtils.addErrorMessage(context, this, "error.inputFile.maxRequestSize",
633                        new Object[] { Framework.getProperty("nuxeo.jsf.maxRequestSize") });
634            } else if (componentId.equals(getFacet(UPLOAD_FACET_NAME).getClientId())) {
635                ComponentUtils.addErrorMessage(context, this, "error.inputFile.maxSize",
636                        new Object[] { Framework.getProperty("nuxeo.jsf.maxFileSize") });
637            }
638        }
639    }
640
641    // state holder
642
643    @Override
644    public Object saveState(FacesContext context) {
645        Object[] values = new Object[6];
646        values[0] = super.saveState(context);
647        values[1] = filename;
648        values[2] = editFilename;
649        values[3] = onchange;
650        values[4] = onclick;
651        values[5] = onselect;
652        return values;
653    }
654
655    @Override
656    public void restoreState(FacesContext context, Object state) {
657        Object[] values = (Object[]) state;
658        super.restoreState(context, values[0]);
659        filename = (String) values[1];
660        editFilename = (Boolean) values[2];
661        onchange = (String) values[3];
662        onclick = (String) values[4];
663        onselect = (String) values[5];
664    }
665
666}