// // ======================================================================== // Copyright (c) 1995-2017 Mort Bay Consulting Pty. Ltd. // ------------------------------------------------------------------------ // All rights reserved. This program and the accompanying materials // are made available under the terms of the Eclipse Public License v1.0 // and Apache License v2.0 which accompanies this distribution. // // The Eclipse Public License is available at // http://www.eclipse.org/legal/epl-v10.html // // The Apache License v2.0 is available at // http://www.opensource.org/licenses/apache2.0.php // // You may elect to redistribute this code under either of these licenses. // ======================================================================== // package org.eclipse.jetty.server.session; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.FilenameFilter; import java.io.IOException; import java.io.InputStream; import java.io.ObjectOutputStream; import java.io.OutputStream; import java.nio.file.Files; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.StringTokenizer; import java.util.concurrent.atomic.AtomicReference; import org.eclipse.jetty.util.ClassLoadingObjectInputStream; import org.eclipse.jetty.util.StringUtil; import org.eclipse.jetty.util.annotation.ManagedAttribute; import org.eclipse.jetty.util.annotation.ManagedObject; import org.eclipse.jetty.util.log.Log; import org.eclipse.jetty.util.log.Logger; /** * FileSessionDataStore * * A file-based store of session data. */ @ManagedObject public class FileSessionDataStore extends AbstractSessionDataStore { private final static Logger LOG = Log.getLogger("org.eclipse.jetty.server.session"); private File _storeDir; private boolean _deleteUnrestorableFiles = false; @Override protected void doStart() throws Exception { initializeStore(); super.doStart(); } @Override protected void doStop() throws Exception { super.doStop(); } @ManagedAttribute(value="dir where sessions are stored", readonly=true) public File getStoreDir() { return _storeDir; } public void setStoreDir(File storeDir) { checkStarted(); _storeDir = storeDir; } public boolean isDeleteUnrestorableFiles() { return _deleteUnrestorableFiles; } public void setDeleteUnrestorableFiles(boolean deleteUnrestorableFiles) { checkStarted(); _deleteUnrestorableFiles = deleteUnrestorableFiles; } /** * @see org.eclipse.jetty.server.session.SessionDataStore#delete(java.lang.String) */ @Override public boolean delete(String id) throws Exception { File file = null; if (_storeDir != null) { file = getFile(_storeDir, id); if (file != null && file.exists() && file.getParentFile().equals(_storeDir)) { return file.delete(); } } return false; } /** * @see org.eclipse.jetty.server.session.SessionDataStore#getExpired(Set) */ @Override public Set<String> doGetExpired(final Set<String> candidates) { final long now = System.currentTimeMillis(); HashSet<String> expired = new HashSet<String>(); HashSet<String> idsWithContext = new HashSet<>(); //one pass to get all idWithContext File [] files = _storeDir.listFiles(new FilenameFilter() { @Override public boolean accept(File dir, String name) { if (dir != _storeDir) return false; //dir may contain files that don't match our naming pattern if (!match(name)) { return false; } String idWithContext = getIdWithContextFromString(name); if (!StringUtil.isBlank(idWithContext)) idsWithContext.add(idWithContext); return true; } }); //got the list of all sessionids with their contexts, remove all old files for each one for (String idWithContext:idsWithContext) { deleteOldFiles(_storeDir, idWithContext); } //now find sessions that have expired in any context files = _storeDir.listFiles(new FilenameFilter() { @Override public boolean accept(File dir, String name) { if (dir != _storeDir) return false; //dir may contain files that don't match our naming pattern if (!match(name)) return false; try { long expiry = getExpiryFromString(name); return expiry > 0 && expiry < now; } catch (Exception e) { return false; } } }); if (files != null) { for (File f:files) { expired.add(getIdFromFile(f)); } } //check candidates that were not found to be expired, perhaps they no //longer exist and they should be expired for (String c:candidates) { if (!expired.contains(c)) { //check if the file exists File f = getFile(_storeDir, c); if (f == null || !f.exists()) expired.add(c); } } return expired; } /** * @see org.eclipse.jetty.server.session.SessionDataStore#load(java.lang.String) */ @Override public SessionData load(String id) throws Exception { final AtomicReference<SessionData> reference = new AtomicReference<SessionData>(); final AtomicReference<Exception> exception = new AtomicReference<Exception>(); Runnable r = new Runnable() { public void run () { //get rid of all but the newest file for a session File file = deleteOldFiles(_storeDir, getIdWithContext(id)); if (file == null || !file.exists()) { if (LOG.isDebugEnabled()) LOG.debug("No file: {}",file); return; } try (FileInputStream in = new FileInputStream(file)) { SessionData data = load(in, id); data.setLastSaved(file.lastModified()); reference.set(data); } catch (UnreadableSessionDataException e) { if (isDeleteUnrestorableFiles() && file.exists() && file.getParentFile().equals(_storeDir)) { file.delete(); LOG.warn("Deleted unrestorable file for session {}", id); } exception.set(e); } catch (Exception e) { exception.set(e); } } }; //ensure this runs with the context classloader set _context.run(r); if (exception.get() != null) throw exception.get(); return reference.get(); } /** * @see org.eclipse.jetty.server.session.AbstractSessionDataStore#doStore(java.lang.String, org.eclipse.jetty.server.session.SessionData, long) */ @Override public void doStore(String id, SessionData data, long lastSaveTime) throws Exception { File file = null; if (_storeDir != null) { //remove any existing files for the session deleteAllFiles(_storeDir, getIdWithContext(id)); //make a fresh file using the latest session expiry file = new File(_storeDir, getIdWithContextAndExpiry(data)); try(FileOutputStream fos = new FileOutputStream(file,false)) { save(fos, id, data); } catch (Exception e) { e.printStackTrace(); if (file != null) file.delete(); // No point keeping the file if we didn't save the whole session throw new UnwriteableSessionDataException(id, _context,e); } } } /** * */ public void initializeStore () { if (_storeDir == null) throw new IllegalStateException("No file store specified"); if (!_storeDir.exists()) _storeDir.mkdirs(); } /** * @see org.eclipse.jetty.server.session.SessionDataStore#isPassivating() */ @Override @ManagedAttribute(value="are sessions serialized by this store", readonly=true) public boolean isPassivating() { return true; } /** * @see org.eclipse.jetty.server.session.SessionDataStore#exists(java.lang.String) */ @Override public boolean exists(String id) throws Exception { File sessionFile = deleteOldFiles(_storeDir, getIdWithContext(id)); if (sessionFile == null || !sessionFile.exists()) return false; //check the expiry long expiry = getExpiryFromFile(sessionFile); if (expiry <= 0) return true; //never expires else return (expiry > System.currentTimeMillis()); //hasn't yet expired } /* ------------------------------------------------------------ */ /** * @param os the output stream to save to * @param id identity of the session * @param data the info of the session * @throws IOException */ private void save(OutputStream os, String id, SessionData data) throws IOException { DataOutputStream out = new DataOutputStream(os); out.writeUTF(id); out.writeUTF(_context.getCanonicalContextPath()); out.writeUTF(_context.getVhost()); out.writeUTF(data.getLastNode()); out.writeLong(data.getCreated()); out.writeLong(data.getAccessed()); out.writeLong(data.getLastAccessed()); out.writeLong(data.getCookieSet()); out.writeLong(data.getExpiry()); out.writeLong(data.getMaxInactiveMs()); List<String> keys = new ArrayList<String>(data.getKeys()); out.writeInt(keys.size()); ObjectOutputStream oos = new ObjectOutputStream(out); for (String name:keys) { oos.writeUTF(name); oos.writeObject(data.getAttribute(name)); } } /** * Get the session id with its context. * * @param id identity of session * @return the session id plus context */ private String getIdWithContext (String id) { return _context.getCanonicalContextPath()+"_"+_context.getVhost()+"_"+id; } /** * Get the session id with its context and its expiry time * @param data * @return the session id plus context and expiry */ private String getIdWithContextAndExpiry (SessionData data) { return ""+data.getExpiry()+"_"+getIdWithContext(data.getId()); } /** * Work out which session id the file relates to. * @param file the file to check * @return the session id the file relates to. */ private String getIdFromFile (File file) { if (file == null) return null; String name = file.getName(); return name.substring(name.lastIndexOf('_')+1); } /** * Get the expiry time of the session stored in the file. * @param file the file from which to extract the expiry time * @return the expiry time */ private long getExpiryFromFile (File file) { if (file == null) return 0; return getExpiryFromString(file.getName()); } private long getExpiryFromString (String filename) { if (StringUtil.isBlank(filename) || filename.indexOf("_") < 0) throw new IllegalStateException ("Invalid or missing filename"); String s = filename.substring(0, filename.indexOf('_')); return (s==null?0:Long.parseLong(s)); } /** * Extract the session id and context from the filename. * @param file the file whose name to use * @return the session id plus context */ private String getIdWithContextFromFile (File file) { if (file == null) return null; String s = getIdWithContextFromString(file.getName()); return s; } /** * Extract the session id and context from the filename * @param filename the name of the file to use * @return the session id plus context */ private String getIdWithContextFromString (String filename) { if (StringUtil.isBlank(filename) || filename.indexOf('_') < 0) return null; return filename.substring(filename.indexOf('_')+1); } /** * Check if the filename matches our session pattern * @param filename * @return */ private boolean match (String filename) { if (StringUtil.isBlank(filename)) return false; String[] parts = filename.split("_"); //Need at least 4 parts for a valid filename if (parts.length < 4) return false; return true; } /** * Find a File for the session id for the current context. * * @param storeDir the session storage directory * @param id the session id * @return the file */ private File getFile (final File storeDir, final String id) { File[] files = storeDir.listFiles (new FilenameFilter() { /** * @see java.io.FilenameFilter#accept(java.io.File, java.lang.String) */ @Override public boolean accept(File dir, String name) { if (dir != storeDir) return false; return (name.contains(getIdWithContext(id))); } }); if (files == null || files.length < 1) return null; return files[0]; } /** * Remove all existing session files for the session in the context * @param storeDir where the session files are stored * @param idInContext the session id within a particular context */ private void deleteAllFiles(final File storeDir, final String idInContext) { File[] files = storeDir.listFiles (new FilenameFilter() { /** * @see java.io.FilenameFilter#accept(java.io.File, java.lang.String) */ @Override public boolean accept(File dir, String name) { if (dir != storeDir) return false; return (name.contains(idInContext)); } }); //no files for that id if (files == null || files.length < 1) return; //delete all files for (File f:files) { try { Files.deleteIfExists(f.toPath()); } catch (Exception e) { LOG.warn("Unable to delete session file", e); } } } /** * Delete all but the most recent file for a given session id in a context. * * @param storeDir the directory in which sessions are stored * @param idWithContext the id of the session * @return the most recent remaining file for the session, can be null */ private File deleteOldFiles (final File storeDir, final String idWithContext) { File[] files = storeDir.listFiles (new FilenameFilter() { /** * @see java.io.FilenameFilter#accept(java.io.File, java.lang.String) */ @Override public boolean accept(File dir, String name) { if (dir != storeDir) return false; if (!match(name)) return false; return (name.contains(idWithContext)); } }); //no file for that session if (files == null || files.length == 0) return null; //delete all but the most recent file File newest = null; for (File f:files) { try { if (newest == null) { //haven't looked at any files yet newest = f; } else { if (f.lastModified() > newest.lastModified()) { //this file is more recent Files.deleteIfExists(newest.toPath()); newest = f; } else if (f.lastModified() < newest.lastModified()) { //this file is older Files.deleteIfExists(f.toPath()); } else { //files have same last modified times, decide based on latest expiry time long exp1 = getExpiryFromFile(newest); long exp2 = getExpiryFromFile(f); if (exp2 >= exp1) { //this file has a later expiry date Files.deleteIfExists(newest.toPath()); newest = f; } else { //this file has an earlier expiry date Files.deleteIfExists(f.toPath()); } } } } catch (Exception e) { LOG.warn("Unable to delete old session file", e); } } return newest; } /** * @param is inputstream containing session data * @param expectedId the id we've been told to load * @return the session data * @throws Exception */ private SessionData load (InputStream is, String expectedId) throws Exception { String id = null; //the actual id from inside the file try { SessionData data = null; DataInputStream di = new DataInputStream(is); id = di.readUTF(); String contextPath = di.readUTF(); String vhost = di.readUTF(); String lastNode = di.readUTF(); long created = di.readLong(); long accessed = di.readLong(); long lastAccessed = di.readLong(); long cookieSet = di.readLong(); long expiry = di.readLong(); long maxIdle = di.readLong(); data = newSessionData(id, created, accessed, lastAccessed, maxIdle); data.setContextPath(contextPath); data.setVhost(vhost); data.setLastNode(lastNode); data.setCookieSet(cookieSet); data.setExpiry(expiry); data.setMaxInactiveMs(maxIdle); // Attributes restoreAttributes(di, di.readInt(), data); return data; } catch (Exception e) { throw new UnreadableSessionDataException(expectedId, _context, e); } } /** * @param is inputstream containing session data * @param size number of attributes * @param data the data to restore to * @throws Exception */ private void restoreAttributes (InputStream is, int size, SessionData data) throws Exception { if (size>0) { // input stream should not be closed here Map<String,Object> attributes = new HashMap<String,Object>(); ClassLoadingObjectInputStream ois = new ClassLoadingObjectInputStream(is); for (int i=0; i<size;i++) { String key = ois.readUTF(); Object value = ois.readObject(); attributes.put(key,value); } data.putAllAttributes(attributes); } } /** * @see org.eclipse.jetty.server.session.AbstractSessionDataStore#toString() */ @Override public String toString() { return String.format("%s[dir=%s,deleteUnrestorableFiles=%b]",super.toString(),_storeDir,_deleteUnrestorableFiles); } }