    001// Licensed under the Apache License, Version 2.0 (the "License");
    002// you may not use this file except in compliance with the License.
    003// You may obtain a copy of the License at
    004//
    005//     http://www.apache.org/licenses/LICENSE-2.0
    006//
    007// Unless required by applicable law or agreed to in writing, software
    008// distributed under the License is distributed on an "AS IS" BASIS,
    009// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    010// See the License for the specific language governing permissions and
    011// limitations under the License.
    012
    013package org.apache.tapestry5.internal.services;
    014
    015import org.apache.tapestry5.Link;
    016import org.apache.tapestry5.ioc.ScopeConstants;
    017import org.apache.tapestry5.ioc.annotations.Scope;
    018import org.apache.tapestry5.ioc.internal.util.CollectionFactory;
    019import org.apache.tapestry5.ioc.internal.util.InternalUtils;
    020import org.apache.tapestry5.services.*;
    021
    022import java.io.ObjectInputStream;
    023import java.io.ObjectOutputStream;
    024import java.io.Serializable;
    025import java.util.Collection;
    026import java.util.Collections;
    027import java.util.Map;
    028
    029/**
    030 * Manages client-persistent values on behalf of a {@link ClientPersistentFieldStorageImpl}. Some effort is made to
    031 * ensure that we don't uncessarily convert between objects and Base64 (the encoding used to record the value on the
    032 * client).
    033 */
    034@Scope(ScopeConstants.PERTHREAD)
    035public class ClientPersistentFieldStorageImpl implements ClientPersistentFieldStorage
    036{
    037    static final String PARAMETER_NAME = "t:state:client";
    038
    039    private static class Key implements Serializable
    040    {
    041        private static final long serialVersionUID = -2741540370081645945L;
    042
    043        private final String pageName;
    044
    045        private final String componentId;
    046
    047        private final String fieldName;
    048
    049        Key(String pageName, String componentId, String fieldName)
    050        {
    051            this.pageName = pageName;
    052            this.componentId = componentId;
    053            this.fieldName = fieldName;
    054        }
    055
    056        public boolean matches(String pageName)
    057        {
    058            return this.pageName.equals(pageName);
    059        }
    060
    061        public PersistentFieldChange toChange(Object value)
    062        {
    063            return new PersistentFieldChangeImpl(componentId == null ? "" : componentId,
    064                    fieldName, value);
    065        }
    066
    067        @Override
    068        public int hashCode()
    069        {
    070            final int PRIME = 31;
    071
    072            int result = 1;
    073
    074            result = PRIME * result + ((componentId == null) ? 0 : componentId.hashCode());
    075
    076            // fieldName and pageName are never null
    077
    078            result = PRIME * result + fieldName.hashCode();
    079            result = PRIME * result + pageName.hashCode();
    080
    081            return result;
    082        }
    083
    084        @Override
    085        public boolean equals(Object obj)
    086        {
    087            if (this == obj) return true;
    088            if (obj == null) return false;
    089            if (getClass() != obj.getClass()) return false;
    090            final Key other = (Key) obj;
    091
    092            // fieldName and pageName are never null
    093
    094            if (!fieldName.equals(other.fieldName)) return false;
    095            if (!pageName.equals(other.pageName)) return false;
    096
    097            if (componentId == null)
    098            {
    099                if (other.componentId != null) return false;
    100            } else if (!componentId.equals(other.componentId)) return false;
    101
    102            return true;
    103        }
    104    }
    105
    106    private final ClientDataEncoder clientDataEncoder;
    107
    108    private final SessionPersistedObjectAnalyzer analyzer;
    109
    110    private final Map<Key, Object> persistedValues = CollectionFactory.newMap();
    111
    112    private String clientData;
    113
    114    private boolean mapUptoDate = false;
    115
    116    public ClientPersistentFieldStorageImpl(Request request, ClientDataEncoder clientDataEncoder, SessionPersistedObjectAnalyzer analyzer)
    117    {
    118        this.clientDataEncoder = clientDataEncoder;
    119        this.analyzer = analyzer;
    120
    121        // This, here, is the problem of TAPESTRY-2501; this call can predate
    122        // the check to set the character set based on meta data of the page.
    123
    124        String value = request.getParameter(PARAMETER_NAME);
    125
    126        // MIME can encode to a '+' character; the browser converts that to a space; we convert it
    127        // back.
    128
    129        clientData = value == null ? null : value.replace(' ', '+');
    130    }
    131
    132    public void updateLink(Link link)
    133    {
    134        refreshClientData();
    135
    136        if (clientData != null) link.addParameter(PARAMETER_NAME, clientData);
    137    }
    138
    139    public Collection<PersistentFieldChange> gatherFieldChanges(String pageName)
    140    {
    141        refreshMap();
    142
    143        if (persistedValues.isEmpty()) return Collections.emptyList();
    144
    145        Collection<PersistentFieldChange> result = CollectionFactory.newList();
    146
    147        for (Map.Entry<Key, Object> e : persistedValues.entrySet())
    148        {
    149            Key key = e.getKey();
    150
    151            if (key.matches(pageName)) result.add(key.toChange(e.getValue()));
    152        }
    153
    154        return result;
    155    }
    156
    157    public void discardChanges(String pageName)
    158    {
    159        refreshMap();
    160
    161        Collection<Key> removedKeys = CollectionFactory.newList();
    162
    163        for (Key key : persistedValues.keySet())
    164        {
    165            if (key.pageName.equals(pageName)) removedKeys.add(key);
    166        }
    167
    168        for (Key key : removedKeys)
    169        {
    170            persistedValues.remove(key);
    171            clientData = null;
    172        }
    173    }
    174
    175    public void postChange(String pageName, String componentId, String fieldName, Object newValue)
    176    {
    177        refreshMap();
    178
    179        Key key = new Key(pageName, componentId, fieldName);
    180
    181        if (newValue == null)
    182            persistedValues.remove(key);
    183        else
    184        {
    185            if (!Serializable.class.isInstance(newValue))
    186                throw new IllegalArgumentException(String.format("State persisted on the client must be serializable, but %s does not implement the Serializable interface.", newValue));
    187
    188            persistedValues.put(key, newValue);
    189        }
    190
    191        clientData = null;
    192    }
    193
    194    /**
    195     * Refreshes the _persistedValues map if it is not up to date.
    196     */
    197    @SuppressWarnings("unchecked")
    198    private void refreshMap()
    199    {
    200        if (mapUptoDate) return;
    201
    202        // Parse the client data to form the map.
    203
    204        restoreMapFromClientData();
    205
    206        mapUptoDate = true;
    207    }
    208
    209    /**
    210     * Restores the _persistedValues map from the client data provided in the incoming Request.
    211     */
    212    private void restoreMapFromClientData()
    213    {
    214        persistedValues.clear();
    215
    216        if (clientData == null) return;
    217
    218        ObjectInputStream in = null;
    219
    220        try
    221        {
    222            in = clientDataEncoder.decodeClientData(clientData);
    223
    224            int count = in.readInt();
    225
    226            for (int i = 0; i < count; i++)
    227            {
    228                Key key = (Key) in.readObject();
    229                Object value = in.readObject();
    230
    231                persistedValues.put(key, value);
    232            }
    233        } catch (Exception ex)
    234        {
    235            throw new RuntimeException("Serialized client state was corrupted. This may indicate that too much state is being stored, which can cause the encoded string to be truncated by the client web browser.", ex);
    236        } finally
    237        {
    238            InternalUtils.close(in);
    239        }
    240    }
    241
    242    private void refreshClientData()
    243    {
    244        // TAP5-2269: Even in the absense of a change to a persistent field, a mutable persistent object
    245        // may have changed.
    246
    247        if (clientData != null)
    248        {
    249            for (Object value : persistedValues.values())
    250            {
    251                if (analyzer.checkAndResetDirtyState(value))
    252                {
    253                    clientData = null;
    254                    break;
    255                }
    256            }
    257        }
    258
    259        // Client data will be null after a change to the map, or if there was no client data in the
    260        // request. In any other case where the client data is non-null, it is by definition
    261        // up-to date (since it is reset to null any time there's a change to the map).
    262
    263        if (clientData != null) return;
    264
    265        // Very typical: we're refreshing the client data but haven't created the map yet, and there
    266        // was no value in the request. Leave it as null.
    267
    268        if (!mapUptoDate) return;
    269
    270        // Null is also appropriate when the persisted values are empty.
    271
    272        if (persistedValues.isEmpty()) return;
    273
    274        // Otherwise, time to update clientData from persistedValues
    275
    276        ClientDataSink sink = clientDataEncoder.createSink();
    277
    278        ObjectOutputStream os = sink.getObjectOutputStream();
    279
    280        try
    281        {
    282            os.writeInt(persistedValues.size());
    283
    284            for (Map.Entry<Key, Object> e : persistedValues.entrySet())
    285            {
    286                os.writeObject(e.getKey());
    287                os.writeObject(e.getValue());
    288            }
    289        } catch (Exception ex)
    290        {
    291            throw new RuntimeException(ex.getMessage(), ex);
    292        } finally
    293        {
    294            InternalUtils.close(os);
    295        }
    296
    297        clientData = sink.getClientData();
    298    }
    299}