blob: 71e62cf2258fba44b218f2c983d78b4e4c594bfb [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*
*/
package org.apache.james.sieverepository.file;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.file.Files;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.Scanner;
import java.util.function.Predicate;
import java.util.stream.Stream;
import javax.inject.Inject;
import org.apache.commons.io.FileUtils;
import org.apache.james.core.Username;
import org.apache.james.core.quota.QuotaSizeLimit;
import org.apache.james.filesystem.api.FileSystem;
import org.apache.james.sieverepository.api.ScriptContent;
import org.apache.james.sieverepository.api.ScriptName;
import org.apache.james.sieverepository.api.ScriptSummary;
import org.apache.james.sieverepository.api.SieveRepository;
import org.apache.james.sieverepository.api.exception.DuplicateException;
import org.apache.james.sieverepository.api.exception.IsActiveException;
import org.apache.james.sieverepository.api.exception.QuotaExceededException;
import org.apache.james.sieverepository.api.exception.QuotaNotFoundException;
import org.apache.james.sieverepository.api.exception.ScriptNotFoundException;
import org.apache.james.sieverepository.api.exception.StorageException;
import com.google.common.collect.ImmutableList;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* <code>SieveFileRepository</code> manages sieve scripts stored on the file system.
* <p>The sieve root directory is a sub-directory of the application base directory named "sieve".
* Scripts are stored in sub-directories of the sieve root directory, each with the name of the
* associated user.
*/
public class SieveFileRepository implements SieveRepository {
private static final String SIEVE_ROOT = FileSystem.FILE_PROTOCOL + "sieve/";
private static final String UTF_8 = "UTF-8";
private static final String FILE_NAME_QUOTA = ".quota";
private static final String FILE_NAME_ACTIVE = ".active";
private static final List<String> SYSTEM_FILES = Arrays.asList(FILE_NAME_QUOTA, FILE_NAME_ACTIVE);
private static final int MAX_BUFF_SIZE = 32768;
public static final String SIEVE_EXTENSION = ".sieve";
private final FileSystem fileSystem;
private final File root;
private final Object lock = new Object();
/**
* Read a file with the specified encoding into a String
*
* @param file
* @param encoding
* @return
* @throws FileNotFoundException
*/
protected static String toString(File file, String encoding) throws FileNotFoundException {
String script = null;
try (Scanner scanner = new Scanner(file, encoding)) {
scanner.useDelimiter("\\A");
script = scanner.next();
}
return script;
}
protected static void toFile(File file, String content) throws StorageException {
// Create a temporary file
int bufferSize = Math.min(content.length(), MAX_BUFF_SIZE);
File tmpFile = null;
try {
tmpFile = Files.createTempFile(file.getParentFile().toPath(), "", ".tmp").toFile();
try (Writer out = new OutputStreamWriter(new BufferedOutputStream(
new FileOutputStream(tmpFile), bufferSize), UTF_8)) {
out.write(content);
}
} catch (IOException ex) {
FileUtils.deleteQuietly(tmpFile);
throw new StorageException(ex);
}
// Does the file exist?
// If so, make a backup
File backupFile = new File(file.getParentFile(), file.getName() + ".bak");
if (file.exists()) {
try {
FileUtils.copyFile(file, backupFile);
} catch (IOException ex) {
throw new StorageException(ex);
}
}
// Copy the temporary file to its final name
try {
FileUtils.copyFile(tmpFile, file);
} catch (IOException ex) {
throw new StorageException(ex);
}
// Tidy up
if (tmpFile.exists()) {
FileUtils.deleteQuietly(tmpFile);
}
if (backupFile.exists()) {
FileUtils.deleteQuietly(backupFile);
}
}
@Inject
public SieveFileRepository(FileSystem fileSystem) throws IOException {
this.fileSystem = fileSystem;
this.root = fileSystem.getFile(SIEVE_ROOT);
FileUtils.forceMkdir(root);
}
@Override
public void deleteScript(Username username, ScriptName name) throws ScriptNotFoundException, IsActiveException, StorageException {
synchronized (lock) {
File file = getScriptFile(username, name);
if (isActiveFile(username, file)) {
throw new IsActiveException("User: " + username.asString() + "Script: " + name);
}
try {
FileUtils.forceDelete(file);
} catch (IOException ex) {
throw new StorageException(ex);
}
}
}
@Override
public InputStream getScript(Username username, ScriptName name) throws ScriptNotFoundException, StorageException {
InputStream script;
try {
script = new FileInputStream(getScriptFile(username, name));
} catch (FileNotFoundException ex) {
throw new ScriptNotFoundException(ex);
}
return script;
}
/**
* The default quota, if any, is stored in file '.quota' in the sieve root directory. Quotas for
* specific users are stored in file '.quota' in the user's directory.
*
* The '.quota' file contains a single positive integer value representing the quota in octets.
*/
@Override
public void haveSpace(Username username, ScriptName name, long size) throws QuotaExceededException, StorageException {
long usedSpace = Arrays.stream(getUserDirectory(username).listFiles())
.filter(file -> !(file.getName().equals(name.getValue()) || SYSTEM_FILES.contains(file.getName())))
.mapToLong(File::length)
.sum();
long quota = Long.MAX_VALUE;
File file = getQuotaFile(username);
if (!file.exists()) {
file = getQuotaFile();
}
if (file.exists()) {
try (Scanner scanner = new Scanner(file, UTF_8)) {
quota = scanner.nextLong();
} catch (FileNotFoundException | NoSuchElementException ex) {
// no op
}
}
if ((usedSpace + size) > quota) {
throw new QuotaExceededException(" Quota: " + quota + " Used: " + usedSpace
+ " Requested: " + size);
}
}
@Override
public List<ScriptSummary> listScripts(Username username) throws StorageException {
File activeFile = null;
try {
activeFile = getActiveFile(username);
} catch (ScriptNotFoundException ex) {
// no op
}
Predicate<File> isActive = isActiveValidator(activeFile);
return Stream.of(Optional.ofNullable(getUserDirectory(username).listFiles()).orElse(new File[]{}))
.filter(file -> !SYSTEM_FILES.contains(file.getName()))
.map(file -> new ScriptSummary(new ScriptName(file.getName()), isActive.test(file), file.length()))
.collect(ImmutableList.toImmutableList());
}
@Override
public Flux<ScriptSummary> listScriptsReactive(Username username) {
return Mono.fromCallable(() -> listScripts(username)).flatMapMany(Flux::fromIterable);
}
private Predicate<File> isActiveValidator(File activeFile) {
if (activeFile != null) {
return activeFile::equals;
}
return file -> false;
}
@Override
public void putScript(Username username, ScriptName name, ScriptContent content) throws StorageException, QuotaExceededException {
synchronized (lock) {
File file = new File(getUserDirectory(username), name.getValue());
enforceRoot(file);
haveSpace(username, name, content.length());
toFile(file, content.getValue());
}
}
@Override
public void renameScript(Username username, ScriptName oldName, ScriptName newName)
throws ScriptNotFoundException, DuplicateException, StorageException {
synchronized (lock) {
File oldFile = getScriptFile(username, oldName);
File newFile = new File(getUserDirectory(username), newName.getValue());
enforceRoot(newFile);
if (newFile.exists()) {
throw new DuplicateException("User: " + username.asString() + "Script: " + newName);
}
try {
FileUtils.copyFile(oldFile, newFile);
if (isActiveFile(username, oldFile)) {
setActiveFile(newFile, username, true);
}
FileUtils.forceDelete(oldFile);
} catch (IOException ex) {
throw new StorageException(ex);
}
}
}
@Override
public InputStream getActive(Username username) throws ScriptNotFoundException, StorageException {
InputStream script;
try {
script = new FileInputStream(getActiveFile(username));
} catch (FileNotFoundException ex) {
throw new ScriptNotFoundException(ex);
}
return script;
}
@Override
public ZonedDateTime getActivationDateForActiveScript(Username username) throws StorageException, ScriptNotFoundException {
return ZonedDateTime.ofInstant(Instant.ofEpochMilli(getActiveFile(username).lastModified()), ZoneOffset.UTC);
}
@Override
public void setActive(Username username, ScriptName scriptName) throws ScriptNotFoundException, StorageException {
synchronized (lock) {
// Turn off currently active script, if any
File oldActive = null;
try {
oldActive = getActiveFile(username);
setActiveFile(oldActive, username, false);
} catch (ScriptNotFoundException ex) {
// This is permissible
}
// Turn on the new active script if not an empty name
String name = scriptName.getValue();
if ((null != name) && (!name.trim().isEmpty())) {
try {
setActiveFile(getScriptFile(username, new ScriptName(name)), username, true);
} catch (ScriptNotFoundException ex) {
if (null != oldActive) {
setActiveFile(oldActive, username, true);
}
throw ex;
}
}
}
}
protected File getSieveRootDirectory() throws StorageException {
try {
return fileSystem.getFile(SIEVE_ROOT);
} catch (FileNotFoundException ex1) {
throw new StorageException(ex1);
}
}
protected File getUserDirectory(Username username) throws StorageException {
File file = getUserDirectoryFile(username);
if (!file.exists()) {
ensureUser(username);
}
return file;
}
private void enforceRoot(File file) throws StorageException {
if (!file.toPath().normalize().startsWith(root.toPath().normalize())) {
throw new StorageException(new IllegalStateException("Path traversal attempted"));
}
}
protected File getUserDirectoryFile(Username username) throws StorageException {
final File userFile = new File(getSieveRootDirectory(), username.asString() + '/');
enforceRoot(userFile);
return userFile;
}
protected File getActiveFile(Username username) throws ScriptNotFoundException, StorageException {
File dir = getUserDirectory(username);
String content;
try {
content = toString(new File(dir, FILE_NAME_ACTIVE), UTF_8);
} catch (FileNotFoundException ex) {
throw new ScriptNotFoundException("There is no active script for user " + username.asString());
}
File scriptFile = new File(dir, content);
enforceRoot(scriptFile);
return scriptFile;
}
protected boolean isActiveFile(Username username, File file) throws StorageException {
try {
return 0 == getActiveFile(username).compareTo(file);
} catch (ScriptNotFoundException ex) {
return false;
}
}
protected void setActiveFile(File scriptToBeActivated, Username userName, boolean isActive) throws StorageException {
File personalScriptDirectory = scriptToBeActivated.getParentFile();
File sieveBaseDirectory = personalScriptDirectory.getParentFile();
File activeScriptPersistenceFile = new File(personalScriptDirectory, FILE_NAME_ACTIVE);
File activeScriptCopy = new File(sieveBaseDirectory, userName.asString() + SIEVE_EXTENSION);
enforceRoot(activeScriptPersistenceFile);
enforceRoot(activeScriptCopy);
if (isActive) {
toFile(activeScriptPersistenceFile, scriptToBeActivated.getName());
try {
FileUtils.copyFile(scriptToBeActivated, activeScriptCopy);
} catch (IOException exception) {
throw new StorageException("Can not copy active script to make it accessible for sieve utils", exception);
}
} else {
try {
FileUtils.forceDelete(activeScriptPersistenceFile);
FileUtils.forceDelete(activeScriptCopy);
} catch (IOException ex) {
throw new StorageException(ex);
}
}
}
protected File getScriptFile(Username username, ScriptName name) throws ScriptNotFoundException, StorageException {
if (name.getValue().contains("/")) {
throw new StorageException(new IllegalArgumentException("Script name should not contain '/' as it can allow path traversal"));
}
File file = new File(getUserDirectory(username), name.getValue());
enforceRoot(file);
if (!file.exists()) {
throw new ScriptNotFoundException("User: " + username + "Script: " + name);
}
return file;
}
public void ensureUser(Username username) throws StorageException {
synchronized (lock) {
try {
FileUtils.forceMkdir(getUserDirectoryFile(username));
} catch (IOException e) {
throw new StorageException("Error while creating directory for " + username.asString(), e);
}
}
}
protected File getQuotaFile() throws StorageException {
return new File(getSieveRootDirectory(), FILE_NAME_QUOTA);
}
@Override
public boolean hasDefaultQuota() throws StorageException {
return getQuotaFile().exists();
}
@Override
public QuotaSizeLimit getDefaultQuota() throws QuotaNotFoundException, StorageException {
Long quota = null;
File file = getQuotaFile();
if (file.exists()) {
try (Scanner scanner = new Scanner(file, UTF_8)) {
quota = scanner.nextLong();
} catch (FileNotFoundException | NoSuchElementException ex) {
// no op
}
}
if (null == quota) {
throw new QuotaNotFoundException("No default quota");
}
return QuotaSizeLimit.size(quota);
}
@Override
public synchronized void removeQuota() throws QuotaNotFoundException, StorageException {
File file = getQuotaFile();
if (!file.exists()) {
return;
}
try {
FileUtils.forceDelete(file);
} catch (IOException ex) {
throw new StorageException(ex);
}
}
@Override
public synchronized void setDefaultQuota(QuotaSizeLimit quota) throws StorageException {
File file = getQuotaFile();
String content = Long.toString(quota.asLong());
toFile(file, content);
}
protected File getQuotaFile(Username username) throws StorageException {
return new File(getUserDirectory(username), FILE_NAME_QUOTA);
}
@Override
public boolean hasQuota(Username username) throws StorageException {
return getQuotaFile(username).exists();
}
@Override
public QuotaSizeLimit getQuota(Username username) throws QuotaNotFoundException, StorageException {
Long quota = null;
File file = getQuotaFile(username);
if (file.exists()) {
try (Scanner scanner = new Scanner(file, UTF_8)) {
quota = scanner.nextLong();
} catch (FileNotFoundException | NoSuchElementException ex) {
// no op
}
}
if (null == quota) {
throw new QuotaNotFoundException("No quota for user: " + username.asString());
}
return QuotaSizeLimit.size(quota);
}
@Override
public void removeQuota(Username username) throws QuotaNotFoundException, StorageException {
synchronized (lock) {
File file = getQuotaFile(username);
if (!file.exists()) {
return;
}
try {
FileUtils.forceDelete(file);
} catch (IOException ex) {
throw new StorageException(ex);
}
}
}
@Override
public void setQuota(Username username, QuotaSizeLimit quota) throws StorageException {
synchronized (lock) {
File file = getQuotaFile(username);
String content = Long.toString(quota.asLong());
toFile(file, content);
}
}
@Override
public Mono<Void> resetSpaceUsedReactive(Username username, long spaceUsed) {
return Mono.error(new UnsupportedOperationException());
}
}