summaryrefslogtreecommitdiffstats
path: root/src/main/java/com/gitblit/manager/FilestoreManager.java
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/com/gitblit/manager/FilestoreManager.java')
-rw-r--r--src/main/java/com/gitblit/manager/FilestoreManager.java439
1 files changed, 439 insertions, 0 deletions
diff --git a/src/main/java/com/gitblit/manager/FilestoreManager.java b/src/main/java/com/gitblit/manager/FilestoreManager.java
new file mode 100644
index 00000000..33672e4a
--- /dev/null
+++ b/src/main/java/com/gitblit/manager/FilestoreManager.java
@@ -0,0 +1,439 @@
+/*
+ * Copyright 2015 gitblit.com.
+ *
+ * Licensed 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 com.gitblit.manager;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.RandomAccessFile;
+import java.lang.reflect.Type;
+import java.nio.file.Files;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Date;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Pattern;
+
+import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.IOUtils;
+import org.bouncycastle.util.io.StreamOverflowException;
+import org.eclipse.jetty.io.EofException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.gitblit.IStoredSettings;
+import com.gitblit.Keys;
+import com.gitblit.models.FilestoreModel.Status;
+import com.gitblit.models.FilestoreModel;
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.UserModel;
+import com.gitblit.utils.ArrayUtils;
+import com.gitblit.utils.JsonUtils.GmtDateTypeAdapter;
+import com.google.gson.ExclusionStrategy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/**
+ * FilestoreManager handles files uploaded via:
+ * + git-lfs
+ * + ticket attachment (TBD)
+ *
+ * Files are stored using their SHA256 hash (as per git-lfs)
+ * If the same file is uploaded through different repositories no additional space is used
+ * Access is controlled through the current repository permissions.
+ *
+ * TODO: Identify what and how the actual BLOBs should work with federation
+ *
+ * @author Paul Martin
+ *
+ */
+@Singleton
+public class FilestoreManager implements IFilestoreManager {
+
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+
+ private final IRuntimeManager runtimeManager;
+
+ private final IStoredSettings settings;
+
+ public static final int UNDEFINED_SIZE = -1;
+
+ private static final String METAFILE = "filestore.json";
+
+ private static final String METAFILE_TMP = "filestore.json.tmp";
+
+ protected static final Type METAFILE_TYPE = new TypeToken<Collection<FilestoreModel>>() {}.getType();
+
+ private Map<String, FilestoreModel > fileCache = new ConcurrentHashMap<String, FilestoreModel>();
+
+
+ @Inject
+ FilestoreManager(
+ IRuntimeManager runtimeManager) {
+ this.runtimeManager = runtimeManager;
+ this.settings = runtimeManager.getSettings();
+ }
+
+ @Override
+ public IManager start() {
+
+ //Try to load any existing metadata
+ File metadata = new File(getStorageFolder(), METAFILE);
+
+ if (metadata.exists()) {
+ Collection<FilestoreModel> items = null;
+
+ Gson gson = gson();
+ try (FileReader file = new FileReader(metadata)) {
+ items = gson.fromJson(file, METAFILE_TYPE);
+ file.close();
+
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+
+ for(Iterator<FilestoreModel> itr = items.iterator(); itr.hasNext(); ) {
+ FilestoreModel model = itr.next();
+ fileCache.put(model.oid, model);
+ }
+
+ logger.info("Loaded {} items from filestore metadata file", fileCache.size());
+ }
+ else
+ {
+ logger.info("No filestore metadata file found");
+ }
+
+ return this;
+ }
+
+ @Override
+ public IManager stop() {
+ return this;
+ }
+
+
+ @Override
+ public boolean isValidOid(String oid) {
+ //NOTE: Assuming SHA256 support only as per git-lfs
+ return Pattern.matches("[a-fA-F0-9]{64}", oid);
+ }
+
+ @Override
+ public FilestoreModel.Status addObject(String oid, long size, UserModel user, RepositoryModel repo) {
+
+ //Handle access control
+ if (!user.canPush(repo)) {
+ if (user == UserModel.ANONYMOUS) {
+ return Status.AuthenticationRequired;
+ } else {
+ return Status.Error_Unauthorized;
+ }
+ }
+
+ //Handle object details
+ if (!isValidOid(oid)) { return Status.Error_Invalid_Oid; }
+
+ if (fileCache.containsKey(oid)) {
+ FilestoreModel item = fileCache.get(oid);
+
+ if (!item.isInErrorState() && (size != UNDEFINED_SIZE) && (item.getSize() != size)) {
+ return Status.Error_Size_Mismatch;
+ }
+
+ item.addRepository(repo.name);
+
+ if (item.isInErrorState()) {
+ item.reset(user, size);
+ }
+ } else {
+
+ if (size < 0) {return Status.Error_Invalid_Size; }
+ if ((getMaxUploadSize() != UNDEFINED_SIZE) && (size > getMaxUploadSize())) { return Status.Error_Exceeds_Size_Limit; }
+
+ FilestoreModel model = new FilestoreModel(oid, size, user, repo.name);
+ fileCache.put(oid, model);
+ saveFilestoreModel(model);
+ }
+
+ return fileCache.get(oid).getStatus();
+ }
+
+ @Override
+ public FilestoreModel.Status uploadBlob(String oid, long size, UserModel user, RepositoryModel repo, InputStream streamIn) {
+
+ //Access control and object logic
+ Status state = addObject(oid, size, user, repo);
+
+ if (state != Status.Upload_Pending) {
+ return state;
+ }
+
+ FilestoreModel model = fileCache.get(oid);
+
+ if (!model.actionUpload(user)) {
+ return Status.Upload_In_Progress;
+ } else {
+ long actualSize = 0;
+ File file = getStoragePath(oid);
+
+ try {
+ file.getParentFile().mkdirs();
+ file.createNewFile();
+
+ try (FileOutputStream streamOut = new FileOutputStream(file)) {
+
+ actualSize = IOUtils.copyLarge(streamIn, streamOut);
+
+ streamOut.flush();
+ streamOut.close();
+
+ if (model.getSize() != actualSize) {
+ model.setStatus(Status.Error_Size_Mismatch, user);
+
+ logger.warn(MessageFormat.format("Failed to upload blob {0} due to size mismatch, expected {1} got {2}",
+ oid, model.getSize(), actualSize));
+ } else {
+ String actualOid = "";
+
+ try (FileInputStream fileForHash = new FileInputStream(file)) {
+ actualOid = DigestUtils.sha256Hex(fileForHash);
+ fileForHash.close();
+ }
+
+ if (oid.equalsIgnoreCase(actualOid)) {
+ model.setStatus(Status.Available, user);
+ } else {
+ model.setStatus(Status.Error_Hash_Mismatch, user);
+
+ logger.warn(MessageFormat.format("Failed to upload blob {0} due to hash mismatch, got {1}", oid, actualOid));
+ }
+ }
+ }
+ } catch (Exception e) {
+
+ model.setStatus(Status.Error_Unknown, user);
+ logger.warn(MessageFormat.format("Failed to upload blob {0}", oid), e);
+ } finally {
+ saveFilestoreModel(model);
+ }
+
+ if (model.isInErrorState()) {
+ file.delete();
+ model.removeRepository(repo.name);
+ }
+ }
+
+ return model.getStatus();
+ }
+
+ private FilestoreModel.Status canGetObject(String oid, UserModel user, RepositoryModel repo) {
+
+ //Access Control
+ if (!user.canView(repo)) {
+ if (user == UserModel.ANONYMOUS) {
+ return Status.AuthenticationRequired;
+ } else {
+ return Status.Error_Unauthorized;
+ }
+ }
+
+ //Object Logic
+ if (!isValidOid(oid)) {
+ return Status.Error_Invalid_Oid;
+ }
+
+ if (!fileCache.containsKey(oid)) {
+ return Status.Unavailable;
+ }
+
+ FilestoreModel item = fileCache.get(oid);
+
+ if (item.getStatus() == Status.Available) {
+ return Status.Available;
+ }
+
+ return Status.Unavailable;
+ }
+
+ @Override
+ public FilestoreModel getObject(String oid, UserModel user, RepositoryModel repo) {
+
+ if (canGetObject(oid, user, repo) == Status.Available) {
+ return fileCache.get(oid);
+ }
+
+ return null;
+ }
+
+ @Override
+ public FilestoreModel.Status downloadBlob(String oid, UserModel user, RepositoryModel repo, OutputStream streamOut) {
+
+ //Access control and object logic
+ Status status = canGetObject(oid, user, repo);
+
+ if (status != Status.Available) {
+ return status;
+ }
+
+ FilestoreModel item = fileCache.get(oid);
+
+ if (streamOut != null) {
+ try (FileInputStream streamIn = new FileInputStream(getStoragePath(oid))) {
+
+ IOUtils.copyLarge(streamIn, streamOut);
+
+ streamOut.flush();
+ streamIn.close();
+ } catch (EofException e) {
+ logger.error(MessageFormat.format("Client aborted connection for {0}", oid), e);
+ return Status.Error_Unexpected_Stream_End;
+ } catch (Exception e) {
+ logger.error(MessageFormat.format("Failed to download blob {0}", oid), e);
+ return Status.Error_Unknown;
+ }
+ }
+
+ return item.getStatus();
+ }
+
+ @Override
+ public List<FilestoreModel> getAllObjects() {
+ return new ArrayList<FilestoreModel>(fileCache.values());
+ }
+
+ @Override
+ public File getStorageFolder() {
+ return runtimeManager.getFileOrFolder(Keys.filestore.storageFolder, "${baseFolder}/lfs");
+ }
+
+ @Override
+ public File getStoragePath(String oid) {
+ return new File(getStorageFolder(), oid.substring(0, 2).concat("/").concat(oid.substring(2)));
+ }
+
+ @Override
+ public long getMaxUploadSize() {
+ return settings.getLong(Keys.filestore.maxUploadSize, -1);
+ }
+
+ @Override
+ public long getFilestoreUsedByteCount() {
+ Iterator<FilestoreModel> iterator = fileCache.values().iterator();
+ long total = 0;
+
+ while (iterator.hasNext()) {
+
+ FilestoreModel item = iterator.next();
+ if (item.getStatus() == Status.Available) {
+ total += item.getSize();
+ }
+ }
+
+ return total;
+ }
+
+ @Override
+ public long getFilestoreAvailableByteCount() {
+
+ try {
+ return Files.getFileStore(getStorageFolder().toPath()).getUsableSpace();
+ } catch (IOException e) {
+ logger.error(MessageFormat.format("Failed to retrive available space in Filestore {0}", e));
+ }
+
+ return UNDEFINED_SIZE;
+ };
+
+ private synchronized void saveFilestoreModel(FilestoreModel model) {
+
+ File metaFile = new File(getStorageFolder(), METAFILE);
+ File metaFileTmp = new File(getStorageFolder(), METAFILE_TMP);
+ boolean isNewFile = false;
+
+ try {
+ if (!metaFile.exists()) {
+ metaFile.getParentFile().mkdirs();
+ metaFile.createNewFile();
+ isNewFile = true;
+ }
+ FileUtils.copyFile(metaFile, metaFileTmp);
+
+ } catch (IOException e) {
+ logger.error("Writing filestore model to file {0}, {1}", METAFILE, e);
+ }
+
+ try (RandomAccessFile fs = new RandomAccessFile(metaFileTmp, "rw")) {
+
+ if (isNewFile) {
+ fs.writeBytes("[");
+ } else {
+ fs.seek(fs.length() - 1);
+ fs.writeBytes(",");
+ }
+
+ fs.writeBytes(gson().toJson(model));
+ fs.writeBytes("]");
+
+ fs.close();
+
+ } catch (IOException e) {
+ logger.error("Writing filestore model to file {0}, {1}", METAFILE_TMP, e);
+ }
+
+ try {
+ if (metaFileTmp.exists()) {
+ FileUtils.copyFile(metaFileTmp, metaFile);
+
+ metaFileTmp.delete();
+ } else {
+ logger.error("Writing filestore model to file {0}", METAFILE);
+ }
+ }
+ catch (IOException e) {
+ logger.error("Writing filestore model to file {0}, {1}", METAFILE, e);
+ }
+ }
+
+ /*
+ * Intended for testing purposes only
+ */
+ public void clearFilestoreCache() {
+ fileCache.clear();
+ }
+
+ private static Gson gson(ExclusionStrategy... strategies) {
+ GsonBuilder builder = new GsonBuilder();
+ builder.registerTypeAdapter(Date.class, new GmtDateTypeAdapter());
+ if (!ArrayUtils.isEmpty(strategies)) {
+ builder.setExclusionStrategies(strategies);
+ }
+ return builder.create();
+ }
+
+}