001/*-
002 * #%L
003 * HAPI FHIR Storage api
004 * %%
005 * Copyright (C) 2014 - 2024 Smile CDR, Inc.
006 * %%
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 * You may obtain a copy of the License at
010 *
011 *      http://www.apache.org/licenses/LICENSE-2.0
012 *
013 * Unless required by applicable law or agreed to in writing, software
014 * distributed under the License is distributed on an "AS IS" BASIS,
015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
016 * See the License for the specific language governing permissions and
017 * limitations under the License.
018 * #L%
019 */
020package ca.uhn.fhir.jpa.binstore;
021
022import ca.uhn.fhir.context.ConfigurationException;
023import ca.uhn.fhir.i18n.Msg;
024import ca.uhn.fhir.jpa.binary.api.StoredDetails;
025import ca.uhn.fhir.jpa.binary.svc.BaseBinaryStorageSvcImpl;
026import ca.uhn.fhir.rest.api.server.RequestDetails;
027import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException;
028import com.fasterxml.jackson.annotation.JsonInclude;
029import com.fasterxml.jackson.databind.ObjectMapper;
030import com.fasterxml.jackson.databind.SerializationFeature;
031import com.google.common.base.Charsets;
032import com.google.common.hash.HashingInputStream;
033import jakarta.annotation.Nonnull;
034import jakarta.annotation.Nullable;
035import org.apache.commons.io.FileUtils;
036import org.apache.commons.io.IOUtils;
037import org.apache.commons.io.input.CountingInputStream;
038import org.apache.commons.lang3.StringUtils;
039import org.apache.commons.lang3.Validate;
040import org.hl7.fhir.instance.model.api.IIdType;
041import org.slf4j.Logger;
042import org.slf4j.LoggerFactory;
043
044import java.io.File;
045import java.io.FileInputStream;
046import java.io.FileNotFoundException;
047import java.io.FileOutputStream;
048import java.io.FileWriter;
049import java.io.IOException;
050import java.io.InputStream;
051import java.io.InputStreamReader;
052import java.io.OutputStream;
053import java.io.Reader;
054import java.util.Date;
055
056public class FilesystemBinaryStorageSvcImpl extends BaseBinaryStorageSvcImpl {
057
058        private static final Logger ourLog = LoggerFactory.getLogger(FilesystemBinaryStorageSvcImpl.class);
059        private final File myBasePath;
060        private final ObjectMapper myJsonSerializer;
061
062        public FilesystemBinaryStorageSvcImpl(String theBasePath) {
063                Validate.notBlank(theBasePath);
064
065                myBasePath = new File(theBasePath);
066
067                myJsonSerializer = new ObjectMapper();
068                myJsonSerializer.setSerializationInclusion(JsonInclude.Include.NON_NULL);
069                myJsonSerializer.enable(SerializationFeature.INDENT_OUTPUT);
070
071                createBasePathDirectory();
072        }
073
074        private void createBasePathDirectory() {
075                ourLog.info("Starting binary storage service with base path: {}", myBasePath);
076
077                mkdir(myBasePath);
078        }
079
080        /**
081         * This implementation prevents: \ / | .
082         */
083        @Override
084        public boolean isValidBlobId(String theNewBlobId) {
085                return !StringUtils.containsAny(theNewBlobId, '\\', '/', '|', '.');
086        }
087
088        @Nonnull
089        @Override
090        public StoredDetails storeBlob(
091                        IIdType theResourceId,
092                        String theBlobIdOrNull,
093                        String theContentType,
094                        InputStream theInputStream,
095                        RequestDetails theRequestDetails)
096                        throws IOException {
097
098                String id = super.provideIdForNewBlob(theBlobIdOrNull, null, theRequestDetails, theContentType);
099                File storagePath = getStoragePath(id, true);
100
101                // Write binary file
102                File storageFilename = getStorageFilename(storagePath, theResourceId, id);
103                ourLog.info("Writing to file: {}", storageFilename.getAbsolutePath());
104                CountingInputStream countingInputStream = createCountingInputStream(theInputStream);
105                HashingInputStream hashingInputStream = createHashingInputStream(countingInputStream);
106                try (FileOutputStream outputStream = new FileOutputStream(storageFilename)) {
107                        IOUtils.copy(hashingInputStream, outputStream);
108                }
109
110                // Write descriptor file
111                long count = countingInputStream.getByteCount();
112                StoredDetails details = new StoredDetails(id, count, theContentType, hashingInputStream, new Date());
113                File descriptorFilename = getDescriptorFilename(storagePath, theResourceId, id);
114                ourLog.info("Writing to file: {}", descriptorFilename.getAbsolutePath());
115                try (FileWriter writer = new FileWriter(descriptorFilename)) {
116                        myJsonSerializer.writeValue(writer, details);
117                }
118
119                ourLog.info(
120                                "Stored binary blob with {} bytes and ContentType {} for resource {}",
121                                count,
122                                theContentType,
123                                theResourceId);
124
125                return details;
126        }
127
128        @Override
129        public StoredDetails fetchBlobDetails(IIdType theResourceId, String theBlobId) throws IOException {
130                StoredDetails retVal = null;
131
132                File storagePath = getStoragePath(theBlobId, false);
133                if (storagePath != null) {
134                        File file = getDescriptorFilename(storagePath, theResourceId, theBlobId);
135                        if (file.exists()) {
136                                try (InputStream inputStream = new FileInputStream(file)) {
137                                        try (Reader reader = new InputStreamReader(inputStream, Charsets.UTF_8)) {
138                                                retVal = myJsonSerializer.readValue(reader, StoredDetails.class);
139                                        }
140                                }
141                        }
142                }
143
144                return retVal;
145        }
146
147        @Override
148        public boolean writeBlob(IIdType theResourceId, String theBlobId, OutputStream theOutputStream) throws IOException {
149                InputStream inputStream = getInputStream(theResourceId, theBlobId);
150
151                if (inputStream != null) {
152                        try (inputStream) {
153                                IOUtils.copy(inputStream, theOutputStream);
154                                theOutputStream.close();
155                        }
156                }
157
158                return false;
159        }
160
161        @Nullable
162        private InputStream getInputStream(IIdType theResourceId, String theBlobId) throws FileNotFoundException {
163                File storagePath = getStoragePath(theBlobId, false);
164                InputStream inputStream = null;
165                if (storagePath != null) {
166                        File file = getStorageFilename(storagePath, theResourceId, theBlobId);
167                        if (file.exists()) {
168                                inputStream = new FileInputStream(file);
169                        }
170                }
171                return inputStream;
172        }
173
174        @Override
175        public void expungeBlob(IIdType theResourceId, String theBlobId) {
176                File storagePath = getStoragePath(theBlobId, false);
177                if (storagePath != null) {
178                        File storageFile = getStorageFilename(storagePath, theResourceId, theBlobId);
179                        if (storageFile.exists()) {
180                                delete(storageFile, theBlobId);
181                        }
182                        File descriptorFile = getDescriptorFilename(storagePath, theResourceId, theBlobId);
183                        if (descriptorFile.exists()) {
184                                delete(descriptorFile, theBlobId);
185                        }
186                }
187        }
188
189        @Override
190        public byte[] fetchBlob(IIdType theResourceId, String theBlobId) throws IOException {
191                StoredDetails details = fetchBlobDetails(theResourceId, theBlobId);
192                try (InputStream inputStream = getInputStream(theResourceId, theBlobId)) {
193
194                        if (inputStream != null) {
195                                return IOUtils.toByteArray(inputStream, details.getBytes());
196                        }
197                }
198
199                throw new ResourceNotFoundException(
200                                Msg.code(1327) + "Unknown blob ID: " + theBlobId + " for resource ID " + theResourceId);
201        }
202
203        private void delete(File theStorageFile, String theBlobId) {
204                Validate.isTrue(theStorageFile.delete(), "Failed to delete file for blob %s", theBlobId);
205        }
206
207        @Nonnull
208        private File getDescriptorFilename(File theStoragePath, IIdType theResourceId, String theId) {
209                return getStorageFilename(theStoragePath, theResourceId, theId, ".json");
210        }
211
212        @Nonnull
213        private File getStorageFilename(File theStoragePath, IIdType theResourceId, String theId) {
214                return getStorageFilename(theStoragePath, theResourceId, theId, ".bin");
215        }
216
217        private File getStorageFilename(File theStoragePath, IIdType theResourceId, String theId, String theExtension) {
218                Validate.notBlank(theResourceId.getResourceType());
219                Validate.notBlank(theResourceId.getIdPart());
220
221                String filename = theResourceId.getResourceType() + "_" + theResourceId.getIdPart() + "_" + theId;
222                return new File(theStoragePath, filename + theExtension);
223        }
224
225        private File getStoragePath(String theId, boolean theCreate) {
226                File path = myBasePath;
227                for (int i = 0; i < 10; i++) {
228                        path = new File(path, theId.substring(i, i + 1));
229                        if (!path.exists()) {
230                                if (theCreate) {
231                                        mkdir(path);
232                                } else {
233                                        return null;
234                                }
235                        }
236                }
237                return path;
238        }
239
240        private void mkdir(File theBasePath) {
241                try {
242                        FileUtils.forceMkdir(theBasePath);
243                } catch (IOException e) {
244                        throw new ConfigurationException(Msg.code(1328) + "Unable to create path " + myBasePath + ": " + e);
245                }
246        }
247}