/* * 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.EOFException; 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.slf4j.Logger; import org.slf4j.LoggerFactory; import com.gitblit.IStoredSettings; import com.gitblit.Keys; import com.gitblit.models.FilestoreModel; import com.gitblit.models.FilestoreModel.Status; 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>() {}.getType(); private Map fileCache = new ConcurrentHashMap(); @Inject public FilestoreManager(IRuntimeManager runtimeManager) { this.runtimeManager = runtimeManager; this.settings = runtimeManager.getSettings(); } @Override public IManager start() { // Try to load any existing metadata File dir = getStorageFolder(); dir.mkdirs(); File metadata = new File(dir, METAFILE); if (metadata.exists()) { Collection 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 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 getAllObjects(List viewableRepositories) { List viewableRepositoryNames = new ArrayList(viewableRepositories.size()); for (RepositoryModel repository : viewableRepositories) { viewableRepositoryNames.add(repository.name); } if (viewableRepositoryNames.size() == 0) { return null; } final Collection allFiles = fileCache.values(); List userViewableFiles = new ArrayList(allFiles.size()); for (FilestoreModel file : allFiles) { if (file.isInRepositoryList(viewableRepositoryNames)) { userViewableFiles.add(file); } } return userViewableFiles; } @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 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 */ @Override 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(); } }