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