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        @SuppressWarnings("resource")
519        ResponseWriter writer = context.getResponseWriter();
520        Blob blob = null;
521        try {
522            blob = getCurrentBlob();
523        } catch (ConverterException e) {
524            // can happen -> ignore, don't break rendering
525        }
526        String filename = null;
527        try {
528            filename = getCurrentFilename();
529        } catch (ConverterException e) {
530            // can happen -> ignore, don't break rendering
531        }
532        InputFileInfo fileInfo = getFileInfoSubmittedValue();
533        if (fileInfo == null) {
534            fileInfo = getFileInfoValue();
535        }
536        String currentChoice = fileInfo.getConvertedChoice();
537        boolean temp = InputFileChoice.KEEP_TEMP.equals(currentChoice);
538        List<String> choices = getAvailableChoices(blob, temp);
539
540        String radioClientId = getClientId(context) + NamingContainer.SEPARATOR_CHAR + CHOICE_FACET_NAME;
541        writer.startElement("table", this);
542        writer.writeAttribute("class", "dataInput", null);
543        writer.startElement("tbody", this);
544        writer.writeAttribute("class", getAttributes().get("styleClass"), null);
545        for (String radioChoice : choices) {
546            String id = radioClientId + radioChoice;
547            writer.startElement("tr", this);
548            writer.startElement("td", this);
549            writer.writeAttribute("class", "radioColumn", null);
550            Map<String, String> props = new HashMap<String, String>();
551            props.put("type", "radio");
552            props.put("name", radioClientId);
553            props.put("id", id);
554            props.put("value", radioChoice);
555            if (radioChoice.equals(currentChoice)) {
556                props.put("checked", "checked");
557            }
558            String onchange = getOnchange();
559            if (onchange != null) {
560                props.put("onchange", onchange);
561            }
562            String onclick = getOnclick();
563            if (onclick != null) {
564                props.put("onclick", onclick);
565            }
566            String onselect = getOnselect();
567            if (onselect != null) {
568                props.put("onselect", onselect);
569            }
570            StringBuffer htmlBuffer = new StringBuffer();
571            htmlBuffer.append("<input");
572            for (Map.Entry<String, String> prop : props.entrySet()) {
573                htmlBuffer.append(String.format(" %s=\"%s\"", prop.getKey(), prop.getValue()));
574            }
575            htmlBuffer.append(" />");
576            writer.write(htmlBuffer.toString());
577            writer.endElement("td");
578            writer.startElement("td", this);
579            writer.writeAttribute("class", "fieldColumn", null);
580            String html = "<label for=\"%s\" style=\"float:left\">%s</label>";
581            String label = (String) ComponentUtils.getAttributeValue(this, radioChoice + "Label", null);
582            if (label == null) {
583                label = ComponentUtils.translate(context, "label.inputFile." + radioChoice + "Choice");
584            }
585            writer.write(String.format(html, id, label));
586            writer.write(ComponentUtils.WHITE_SPACE_CHARACTER);
587            if (InputFileChoice.isKeepOrKeepTemp(radioChoice)) {
588                UIComponent downloadFacet = getFacet(DOWNLOAD_FACET_NAME);
589                if (downloadFacet != null) {
590                    // redefined in template
591                    ComponentUtils.encodeComponent(context, downloadFacet);
592                } else {
593                    downloadFacet = getFacet(DEFAULT_DOWNLOAD_FACET_NAME);
594                    if (downloadFacet != null) {
595                        UIOutputFile downloadComp = (UIOutputFile) downloadFacet;
596                        downloadComp.setQueryParent(true);
597                        ComponentUtils.copyValues(this, downloadComp, new String[] { "downloadLabel", "iconRendered" });
598                        ComponentUtils.copyLinkValues(this, downloadComp);
599                        ComponentUtils.encodeComponent(context, downloadComp);
600                    }
601                }
602                if (getEditFilename()) {
603                    writer.write("<br />");
604                    UIComponent filenameFacet = getFacet(EDIT_FILENAME_FACET_NAME);
605                    if (filenameFacet instanceof HtmlInputText) {
606                        HtmlInputText filenameComp = (HtmlInputText) filenameFacet;
607                        filenameComp.setValue(filename);
608                        filenameComp.setLocalValueSet(false);
609                        String onClick = "document.getElementById('%s').checked='checked'";
610                        filenameComp.setOnclick(String.format(onClick, id));
611                        writer.write(ComponentUtils.WHITE_SPACE_CHARACTER);
612                        html = "<label for=\"%s\">%s</label>";
613                        label = (String) ComponentUtils.getAttributeValue(this, "editFilenameLabel", null);
614                        if (label == null) {
615                            label = ComponentUtils.translate(context, "label.inputFile.editFilename");
616                        }
617                        writer.write(String.format(html, filenameComp.getId(), label));
618                        writer.write(ComponentUtils.WHITE_SPACE_CHARACTER);
619                        ComponentUtils.encodeComponent(context, filenameComp);
620                    }
621                }
622            } else if (InputFileChoice.isUpload(radioChoice)) {
623                String onClick = String.format("document.getElementById('%s').checked='checked'", id);
624                uploaderService.getJSFBlobUploader(radioChoice).encodeBeginUpload(this, context, onClick);
625            }
626            writer.endElement("td");
627            writer.endElement("tr");
628        }
629        writer.endElement("tbody");
630        writer.endElement("table");
631        writer.flush();
632    }
633
634    /**
635     * @since 6.1.1
636     */
637    private void notifyPreviousErrors(FacesContext context) {
638        final Object hasError = context.getAttributes().get(NuxeoResponseStateManagerImpl.MULTIPART_SIZE_ERROR_FLAG);
639        final String componentId = (String) context.getAttributes().get(
640                NuxeoResponseStateManagerImpl.MULTIPART_SIZE_ERROR_COMPONENT_ID);
641        if (Boolean.TRUE.equals(hasError)) {
642            if (StringUtils.isBlank(componentId)) {
643                ComponentUtils.addErrorMessage(context, this, "error.inputFile.maxRequestSize",
644                        new Object[] { Framework.getProperty("nuxeo.jsf.maxRequestSize") });
645            } else if (componentId.equals(getFacet(UPLOAD_FACET_NAME).getClientId())) {
646                ComponentUtils.addErrorMessage(context, this, "error.inputFile.maxSize",
647                        new Object[] { Framework.getProperty("nuxeo.jsf.maxFileSize") });
648            }
649        }
650    }
651
652    // state holder
653
654    @Override
655    public Object saveState(FacesContext context) {
656        Object[] values = new Object[6];
657        values[0] = super.saveState(context);
658        values[1] = filename;
659        values[2] = editFilename;
660        values[3] = onchange;
661        values[4] = onclick;
662        values[5] = onselect;
663        return values;
664    }
665
666    @Override
667    public void restoreState(FacesContext context, Object state) {
668        Object[] values = (Object[]) state;
669        super.restoreState(context, values[0]);
670        filename = (String) values[1];
671        editFilename = (Boolean) values[2];
672        onchange = (String) values[3];
673        onclick = (String) values[4];
674        onselect = (String) values[5];
675    }
676
677}