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.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)) {
302            // re-submit stored values
303            if (isLocalValueSet()) {
304                submitted.setBlob(previous.getConvertedBlob());
305                submitted.setFilename(previous.getConvertedFilename());
306            }
307            if (getEditFilename()) {
308                validateFilename(context, submitted);
309            }
310        } else if (InputFileChoice.KEEP.equals(choice)) {
311            // re-submit stored values
312            submitted.setBlob(previous.getConvertedBlob());
313            submitted.setFilename(previous.getConvertedFilename());
314            if (getEditFilename()) {
315                validateFilename(context, submitted);
316            }
317        } else if (InputFileChoice.isUpload(choice)) {
318            try {
319                uploaderService.getJSFBlobUploader(choice).validateUpload(this, context, submitted);
320                if (isValid()) {
321                    submitted.setChoice(InputFileChoice.KEEP_TEMP);
322                }
323            } catch (ValidatorException e) {
324                // set file to null: blob is null but file is not required
325                submitted.setBlob(null);
326                submitted.setFilename(null);
327                submitted.setChoice(InputFileChoice.NONE);
328            }
329        } else if (InputFileChoice.DELETE.equals(choice) || InputFileChoice.NONE.equals(choice)) {
330            submitted.setBlob(null);
331            submitted.setFilename(null);
332        }
333
334        // will need this to call declared validators
335        super.validateValue(context, submitted);
336
337        // If our value is valid, store the new value, erase the
338        // "submitted" value, and emit a ValueChangeEvent if appropriate
339        if (isValid()) {
340            setValue(submitted);
341            setSubmittedValue(null);
342            if (compareValues(previous, submitted)) {
343                queueEvent(new ValueChangeEvent(this, previous, submitted));
344            }
345        }
346    }
347
348    public void validateFilename(FacesContext context, InputFileInfo submitted) {
349        // validate filename
350        UIComponent filenameFacet = getFacet(EDIT_FILENAME_FACET_NAME);
351        if (filenameFacet instanceof EditableValueHolder) {
352            EditableValueHolder filenameComp = (EditableValueHolder) filenameFacet;
353            submitted.setFilename(filenameComp.getLocalValue());
354            String filename;
355            try {
356                filename = submitted.getConvertedFilename();
357            } catch (ConverterException ce) {
358                ComponentUtils.addErrorMessage(context, this, ce.getMessage());
359                setValid(false);
360                return;
361            }
362            submitted.setFilename(filename);
363        }
364    }
365
366    public void updateFilename(FacesContext context, String newFilename) {
367        // set filename by hand after validation
368        ValueExpression ve = getValueExpression("filename");
369        if (ve != null) {
370            ve.setValue(context.getELContext(), newFilename);
371        }
372    }
373
374    @Override
375    public void updateModel(FacesContext context) {
376        if (context == null) {
377            throw new IllegalArgumentException();
378        }
379
380        if (!isValid() || !isLocalValueSet()) {
381            return;
382        }
383        ValueExpression ve = getValueExpression("value");
384        if (ve == null) {
385            return;
386        }
387        try {
388            InputFileInfo local = getFileInfoLocalValue();
389            String choice = local.getConvertedChoice();
390            // set blob and filename
391            if (InputFileChoice.DELETE.equals(choice)) {
392                // set filename first to avoid error in case it maps the blob filename
393                ValueExpression vef = getValueExpression("filename");
394                if (vef != null) {
395                    vef.setValue(context.getELContext(), local.getConvertedFilename());
396                }
397                ve.setValue(context.getELContext(), local.getConvertedBlob());
398                setValue(null);
399                setLocalValueSet(false);
400            } else if (InputFileChoice.isUploadOrKeepTemp(choice)) {
401                // set blob first to avoid error in case the filename maps the blob filename
402                ve.setValue(context.getELContext(), local.getConvertedBlob());
403                setValue(null);
404                setLocalValueSet(false);
405                ValueExpression vef = getValueExpression("filename");
406                if (vef != null) {
407                    vef.setValue(context.getELContext(), local.getConvertedFilename());
408                }
409            } else if (InputFileChoice.KEEP.equals(choice)) {
410                // reset local value
411                setValue(null);
412                setLocalValueSet(false);
413                if (getEditFilename()) {
414                    // set filename
415                    ValueExpression vef = getValueExpression("filename");
416                    if (vef != null) {
417                        vef.setValue(context.getELContext(), local.getConvertedFilename());
418                    }
419                }
420            }
421            return;
422        } catch (ELException e) {
423            String messageStr = e.getMessage();
424            Throwable result = e.getCause();
425            while (result != null && result instanceof ELException) {
426                messageStr = result.getMessage();
427                result = result.getCause();
428            }
429            FacesMessage message;
430            if (messageStr == null) {
431                message = MessageFactory.getMessage(context, UPDATE_MESSAGE_ID, MessageFactory.getLabel(context, this));
432            } else {
433                message = new FacesMessage(FacesMessage.SEVERITY_ERROR, messageStr, messageStr);
434            }
435            context.addMessage(getClientId(context), message);
436            setValid(false);
437        } catch (IllegalArgumentException | ConverterException e) {
438            FacesMessage message = MessageFactory.getMessage(context, UPDATE_MESSAGE_ID,
439                    MessageFactory.getLabel(context, this));
440            context.addMessage(getClientId(context), message);
441            setValid(false);
442        }
443    }
444
445    // rendering methods
446
447    protected List<String> getAvailableChoices(Blob blob, boolean temp) {
448        List<String> choices = new ArrayList<String>(3);
449        boolean isRequired = isRequired();
450        if (blob != null) {
451            choices.add(temp ? InputFileChoice.KEEP_TEMP : InputFileChoice.KEEP);
452        } else if (!isRequired) {
453            choices.add(InputFileChoice.NONE);
454        }
455        boolean allowUpdate = true;
456        if (blob != null) {
457            BlobManager blobManager = Framework.getService(BlobManager.class);
458            BlobProvider blobProvider = blobManager.getBlobProvider(blob);
459            if (blobProvider != null && !blobProvider.supportsUserUpdate()) {
460                allowUpdate = false;
461            }
462        }
463        if (allowUpdate) {
464            for (JSFBlobUploader uploader : uploaderService.getJSFBlobUploaders()) {
465                choices.add(uploader.getChoice());
466            }
467            if (blob != null && !isRequired) {
468                choices.add(InputFileChoice.DELETE);
469            }
470        }
471        return choices;
472    }
473
474    public Blob getCurrentBlob() {
475        Blob blob = null;
476        InputFileInfo submittedFileInfo = getFileInfoSubmittedValue();
477        if (submittedFileInfo != null) {
478            String choice = submittedFileInfo.getConvertedChoice();
479            if (InputFileChoice.isKeepOrKeepTemp(choice)) {
480                // rebuild other info from current value
481                InputFileInfo fileInfo = getFileInfoValue();
482                blob = fileInfo.getConvertedBlob();
483            } else {
484                blob = submittedFileInfo.getConvertedBlob();
485            }
486        } else {
487            InputFileInfo fileInfo = getFileInfoValue();
488            blob = fileInfo.getConvertedBlob();
489        }
490        return blob;
491    }
492
493    public String getCurrentFilename() {
494        String filename = null;
495        InputFileInfo submittedFileInfo = getFileInfoSubmittedValue();
496        if (submittedFileInfo != null) {
497            String choice = submittedFileInfo.getConvertedChoice();
498            if (InputFileChoice.isKeepOrKeepTemp(choice)) {
499                // rebuild it in case it's supposed to be kept
500                InputFileInfo fileInfo = getFileInfoValue();
501                filename = fileInfo.getConvertedFilename();
502            } else {
503                filename = submittedFileInfo.getConvertedFilename();
504            }
505        } else {
506            InputFileInfo fileInfo = getFileInfoValue();
507            filename = fileInfo.getConvertedFilename();
508        }
509        return filename;
510    }
511
512    @Override
513    public void encodeBegin(FacesContext context) throws IOException {
514
515        notifyPreviousErrors(context);
516
517        // not ours to close
518        ResponseWriter writer = context.getResponseWriter();
519        Blob blob = null;
520        try {
521            blob = getCurrentBlob();
522        } catch (ConverterException e) {
523            // can happen -> ignore, don't break rendering
524        }
525        String filename = null;
526        try {
527            filename = getCurrentFilename();
528        } catch (ConverterException e) {
529            // can happen -> ignore, don't break rendering
530        }
531        InputFileInfo fileInfo = getFileInfoSubmittedValue();
532        if (fileInfo == null) {
533            fileInfo = getFileInfoValue();
534        }
535        String currentChoice = fileInfo.getConvertedChoice();
536        boolean temp = InputFileChoice.KEEP_TEMP.equals(currentChoice);
537        List<String> choices = getAvailableChoices(blob, temp);
538
539        String radioClientId = getClientId(context) + NamingContainer.SEPARATOR_CHAR + CHOICE_FACET_NAME;
540        writer.startElement("table", this);
541        writer.writeAttribute("class", "dataInput", null);
542        writer.startElement("tbody", this);
543        writer.writeAttribute("class", getAttributes().get("styleClass"), null);
544        for (String radioChoice : choices) {
545            String id = radioClientId + radioChoice;
546            writer.startElement("tr", this);
547            writer.startElement("td", this);
548            writer.writeAttribute("class", "radioColumn", null);
549            Map<String, String> props = new HashMap<String, String>();
550            props.put("type", "radio");
551            props.put("name", radioClientId);
552            props.put("id", id);
553            props.put("value", radioChoice);
554            if (radioChoice.equals(currentChoice)) {
555                props.put("checked", "checked");
556            }
557            String onchange = getOnchange();
558            if (onchange != null) {
559                props.put("onchange", onchange);
560            }
561            String onclick = getOnclick();
562            if (onclick != null) {
563                props.put("onclick", onclick);
564            }
565            String onselect = getOnselect();
566            if (onselect != null) {
567                props.put("onselect", onselect);
568            }
569            StringBuffer htmlBuffer = new StringBuffer();
570            htmlBuffer.append("<input");
571            for (Map.Entry<String, String> prop : props.entrySet()) {
572                htmlBuffer.append(" " + prop.getKey() + "=\"" + prop.getValue() + "\"");
573            }
574            htmlBuffer.append(" />");
575            writer.write(htmlBuffer.toString());
576            writer.endElement("td");
577            writer.startElement("td", this);
578            writer.writeAttribute("class", "fieldColumn", null);
579            String label = (String) ComponentUtils.getAttributeValue(this, radioChoice + "Label", null);
580            if (label == null) {
581                label = ComponentUtils.translate(context, "label.inputFile." + radioChoice + "Choice");
582            }
583            writer.write("<label for=\"" + id + "\" style=\"float:left\">" + label + "</label>");
584            writer.write(ComponentUtils.WHITE_SPACE_CHARACTER);
585            if (InputFileChoice.isKeepOrKeepTemp(radioChoice)) {
586                UIComponent downloadFacet = getFacet(DOWNLOAD_FACET_NAME);
587                if (downloadFacet != null) {
588                    // redefined in template
589                    ComponentUtils.encodeComponent(context, downloadFacet);
590                } else {
591                    downloadFacet = getFacet(DEFAULT_DOWNLOAD_FACET_NAME);
592                    if (downloadFacet != null) {
593                        UIOutputFile downloadComp = (UIOutputFile) downloadFacet;
594                        downloadComp.setQueryParent(true);
595                        ComponentUtils.copyValues(this, downloadComp, new String[] { "downloadLabel", "iconRendered" });
596                        ComponentUtils.copyLinkValues(this, downloadComp);
597                        ComponentUtils.encodeComponent(context, downloadComp);
598                    }
599                }
600                if (getEditFilename()) {
601                    writer.write("<br />");
602                    UIComponent filenameFacet = getFacet(EDIT_FILENAME_FACET_NAME);
603                    if (filenameFacet instanceof HtmlInputText) {
604                        HtmlInputText filenameComp = (HtmlInputText) filenameFacet;
605                        filenameComp.setValue(filename);
606                        filenameComp.setLocalValueSet(false);
607                        String onClick = "document.getElementById('%s').checked='checked'";
608                        filenameComp.setOnclick(String.format(onClick, id));
609                        writer.write(ComponentUtils.WHITE_SPACE_CHARACTER);
610                        label = (String) ComponentUtils.getAttributeValue(this, "editFilenameLabel", null);
611                        if (label == null) {
612                            label = ComponentUtils.translate(context, "label.inputFile.editFilename");
613                        }
614                        writer.write("<label for=\"" + filenameComp.getId() + "\">" + label + "</label>");
615                        writer.write(ComponentUtils.WHITE_SPACE_CHARACTER);
616                        ComponentUtils.encodeComponent(context, filenameComp);
617                    }
618                }
619            } else if (InputFileChoice.isUpload(radioChoice)) {
620                String onClick = String.format("document.getElementById('%s').checked='checked'", id);
621                uploaderService.getJSFBlobUploader(radioChoice).encodeBeginUpload(this, context, onClick);
622            }
623            writer.endElement("td");
624            writer.endElement("tr");
625        }
626        writer.endElement("tbody");
627        writer.endElement("table");
628        writer.flush();
629    }
630
631    /**
632     * @since 6.1.1
633     */
634    private void notifyPreviousErrors(FacesContext context) {
635        final Object hasError = context.getAttributes().get(NuxeoResponseStateManagerImpl.MULTIPART_SIZE_ERROR_FLAG);
636        final String componentId = (String) context.getAttributes().get(
637                NuxeoResponseStateManagerImpl.MULTIPART_SIZE_ERROR_COMPONENT_ID);
638        if (Boolean.TRUE.equals(hasError)) {
639            if (StringUtils.isBlank(componentId)) {
640                ComponentUtils.addErrorMessage(context, this, "error.inputFile.maxRequestSize",
641                        new Object[] { Framework.getProperty("nuxeo.jsf.maxRequestSize") });
642            } else if (componentId.equals(getFacet(UPLOAD_FACET_NAME).getClientId())) {
643                ComponentUtils.addErrorMessage(context, this, "error.inputFile.maxSize",
644                        new Object[] { Framework.getProperty("nuxeo.jsf.maxFileSize") });
645            }
646        }
647    }
648
649    // state holder
650
651    @Override
652    public Object saveState(FacesContext context) {
653        Object[] values = new Object[6];
654        values[0] = super.saveState(context);
655        values[1] = filename;
656        values[2] = editFilename;
657        values[3] = onchange;
658        values[4] = onclick;
659        values[5] = onselect;
660        return values;
661    }
662
663    @Override
664    public void restoreState(FacesContext context, Object state) {
665        Object[] values = (Object[]) state;
666        super.restoreState(context, values[0]);
667        filename = (String) values[1];
668        editFilename = (Boolean) values[2];
669        onchange = (String) values[3];
670        onclick = (String) values[4];
671        onselect = (String) values[5];
672    }
673
674}