/* (c) 2016 Open Source Geospatial Foundation - all rights reserved
* This code is licensed under the GPL 2.0 license, available at the root
* application directory.
*/
package org.geogig.geoserver.config;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Predicates.notNull;
import static com.google.common.collect.Iterators.filter;
import static com.google.common.collect.Iterators.transform;
import static com.google.common.collect.Lists.newArrayList;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.util.Iterator;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import org.geoserver.platform.resource.Paths;
import org.geoserver.platform.resource.Resource;
import org.geoserver.platform.resource.ResourceStore;
import org.geoserver.platform.resource.Resources;
import org.geotools.util.logging.Logging;
import com.google.common.base.Charsets;
import com.google.common.base.Function;
import com.google.common.base.Predicate;
import com.google.common.base.Throwables;
import com.thoughtworks.xstream.XStream;
/**
* Handles storage for {@link RepositoryInfo}s inside the GeoServer data directory's
* {@code geogig/config/repos/} subdirectory.
* <p>
* {@link RepositoryInfo} instances are created through its default constructor, which assigns a
* {@code null} id, meaning its a new instance and has not yet being saved.
* <p>
* Persistence is handled with {@link XStream} on a one file per {@code RepositoryInfo} bases under
* {@code <data-dir>/geogig/config/repos/}, named {@code RepositoryInfo.getId()+".xml"}.
* <p>
* {@link #save(RepositoryInfo)} sets an id on new instances, which is the String representation of
* a random {@link UUID}.
* <p>
* {@code RepositoryInfo} instances deserialized from XML have its id set by {@link XStream}, and
* {@link #save(RepositoryInfo)} knows its an existing instance and replaces its file.
*
*
*/
public class ConfigStore {
private static final Logger LOGGER = Logging.getLogger(ConfigStore.class);
/**
* Regex pattern to assert the format of ids on {@link #save(RepositoryInfo)}
*/
public static final Pattern UUID_PATTERN = Pattern
.compile("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");
private static final String CONFIG_DIR_NAME = "geogig/config/repos";
private ResourceStore resourceLoader;
private final ReadWriteLock lock;
private static class CachedInfo {
final RepositoryInfo info;
final long lastModified;
CachedInfo(RepositoryInfo info, long lastModified) {
this.info = info;
this.lastModified = lastModified;
}
}
/**
* Map of cached {@link RepositoryInfo} instances key'ed by id
*/
private ConcurrentMap<String, CachedInfo> cache = new ConcurrentHashMap<>();
public ConfigStore(ResourceStore resourceLoader) {
checkNotNull(resourceLoader, "resourceLoader");
this.resourceLoader = resourceLoader;
if (null == Resources.directory(resourceLoader.get(CONFIG_DIR_NAME), true)) {
throw new IllegalStateException("Unable to create config directory " + CONFIG_DIR_NAME);
}
this.lock = new ReentrantReadWriteLock();
}
/**
* Saves a {@link RepositoryInfo} to its {@code <data-dir>/geogig/config/repos/<id>.xml} file.
* <p>
* If {@code info} has no id set, one is assigned, meaning it didn't yet exist. Otherwise its
* xml file is replaced meaning it has been modified.
*
* @return {@code info}, possibly with its id set if it was {@code null}
*
*/
public RepositoryInfo save(RepositoryInfo info) {
checkNotNull(info, "null RepositoryInfo");
ensureIdPresent(info);
checkNotNull(info.getLocation(), "null location URI: %s", info);
lock.writeLock().lock();
Resource resource = resource(info.getId());
try (OutputStream out = resource.out()) {
getConfigredXstream().toXML(info, new OutputStreamWriter(out, Charsets.UTF_8));
long lastmodified = resource.lastmodified();
cache.put(info.getId(), new CachedInfo(info, lastmodified));
} catch (IOException e) {
throw Throwables.propagate(e);
} finally {
lock.writeLock().unlock();
}
return info;
}
public boolean delete(final String id) {
checkNotNull(id, "provided a null id");
checkIdFormat(id);
lock.writeLock().lock();
try {
cache.remove(id);
return resource(id).delete();
} finally {
lock.writeLock().unlock();
}
}
private void checkIdFormat(final String id) {
checkArgument(UUID_PATTERN.matcher(id).matches(), "Id doesn't match UUID format: '%s'", id);
}
private void ensureIdPresent(RepositoryInfo info) {
String id = info.getId();
if (id == null) {
id = UUID.randomUUID().toString();
info.setId(id);
} else {
checkIdFormat(id);
}
}
private Resource resource(String id) {
Resource resource = resourceLoader.get(path(id));
return resource;
}
public Resource getConfigRoot() {
return resourceLoader.get(CONFIG_DIR_NAME);
}
static String path(String infoId) {
return Paths.path(CONFIG_DIR_NAME, infoId + ".xml");
}
/**
* Loads and returns all <b>valid</b> {@link RepositoryInfo}'s from {@code
* <data-dir>/geogig/config/repos/}; any xml file that can't be parsed is ignored.
*/
public List<RepositoryInfo> getRepositories() {
lock.writeLock().lock();
try {
Resource configRoot = getConfigRoot();
List<Resource> list = configRoot.list();
if (null == list) {
return newArrayList();
}
Iterator<Resource> xmlfiles = filter(list.iterator(), FILENAMEFILTER);
return newArrayList(filter(transform(xmlfiles, LOADER), notNull()));
} finally {
lock.writeLock().unlock();
}
}
/**
* Loads the security whitelist.
*/
public List<WhitelistRule> getWhitelist() throws IOException {
lock.readLock().lock();
try {
Resource resource = whitelistResource();
return loadWhitelist(resource);
} finally {
lock.readLock().unlock();
}
}
private Resource whitelistResource() {
return resourceLoader.get("geogig/config/whitelist.xml");
}
private static List<WhitelistRule> loadWhitelist(Resource input) throws IOException {
File parent = input.parent().dir();
File f = new File(parent, input.name());
if (!(parent.exists() && f.exists())) {
return newArrayList();
}
try (Reader reader = new InputStreamReader(input.in(), Charsets.UTF_8)) {
return (List<WhitelistRule>) getConfigredXstream().fromXML(reader);
} catch (Exception e) {
String msg = "Unable to load whitelist " + input.name();
LOGGER.log(Level.WARNING, msg, e);
throw new IOException(msg, e);
}
}
/**
* Saves the security whitelist.
*/
public List<WhitelistRule> saveWhitelist(List<WhitelistRule> whitelist) {
checkNotNull(whitelist);
lock.writeLock().lock();
try (OutputStream out = whitelistResource().out()) {
getConfigredXstream().toXML(whitelist, new OutputStreamWriter(out, Charsets.UTF_8));
} catch (IOException e) {
throw Throwables.propagate(e);
} finally {
lock.writeLock().unlock();
}
return whitelist;
}
/**
* Loads a {@link RepositoryInfo} by {@link RepositoryInfo#getId() id} from its xml file under
* {@code <data-dir>/geogig/config/repos/}
*/
public RepositoryInfo get(final String id) throws IOException {
checkNotNull(id, "provided a null id");
checkIdFormat(id);
lock.readLock().lock();
try {
CachedInfo cached = cache.get(id);
Resource resource = resource(id);
final long lastmodified = resource.lastmodified();
RepositoryInfo info;
if (cached == null || cached.lastModified < lastmodified) {
info = load(resource);
cache.put(id, new CachedInfo(info, lastmodified));
} else {
info = cached.info;
}
return info;
} finally {
lock.readLock().unlock();
}
}
private static RepositoryInfo load(Resource input) throws IOException {
// make an explicit check here because FileSystemResource.file() creates an empty file
File parent = input.parent().dir();
File f = new File(parent, input.name());
if (!(parent.exists() && f.exists())) {
throw new FileNotFoundException("File not found: " + f.getAbsolutePath());
}
RepositoryInfo info;
try (Reader reader = new InputStreamReader(input.in(), Charsets.UTF_8)) {
info = (RepositoryInfo) getConfigredXstream().fromXML(reader);
} catch (Exception e) {
String msg = "Unable to load repo config " + input.name();
LOGGER.log(Level.WARNING, msg, e);
throw new IOException(msg, e);
}
if (info.getLocation() == null) {
throw new IOException("Repository info has incomplete information: " + info);
}
return info;
}
private static XStream getConfigredXstream() {
XStream xStream = new XStream();
xStream.alias("RepositoryInfo", RepositoryInfo.class);
return xStream;
}
private static final Predicate<Resource> FILENAMEFILTER = new Predicate<Resource>() {
@Override
public boolean apply(Resource input) {
return input.name().endsWith(".xml");
}
};
private final Function<Resource, RepositoryInfo> LOADER = new Function<Resource, RepositoryInfo>() {
@Override
public RepositoryInfo apply(final Resource resource) {
try {
RepositoryInfo loaded = load(resource);
CachedInfo cached = cache.get(loaded.getId());
if (cached == null) {
long lastModified = resource.lastmodified();
cached = new CachedInfo(loaded, lastModified);
cache.put(loaded.getId(), cached);
}
return cached.info;
} catch (IOException e) {
LOGGER.log(Level.WARNING, "Error loading RepositoryInfo", e);
return null;
}
}
};
}