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 isValidBinaryContentId(String theNewBinaryContentId) {
085                return !StringUtils.containsAny(theNewBinaryContentId, '\\', '/', '|', '.');
086        }
087
088        @Nonnull
089        @Override
090        public StoredDetails storeBinaryContent(
091                        IIdType theResourceId,
092                        String theBlobIdOrNull,
093                        String theContentType,
094                        InputStream theInputStream,
095                        RequestDetails theRequestDetails)
096                        throws IOException {
097
098                String id = super.provideIdForNewBinaryContent(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 fetchBinaryContentDetails(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 writeBinaryContent(IIdType theResourceId, String theBlobId, OutputStream theOutputStream)
149                        throws IOException {
150                InputStream inputStream = getInputStream(theResourceId, theBlobId);
151
152                if (inputStream != null) {
153                        try (inputStream) {
154                                IOUtils.copy(inputStream, theOutputStream);
155                                theOutputStream.close();
156                        }
157                }
158
159                return false;
160        }
161
162        @Nullable
163        private InputStream getInputStream(IIdType theResourceId, String theBlobId) throws FileNotFoundException {
164                File storagePath = getStoragePath(theBlobId, false);
165                InputStream inputStream = null;
166                if (storagePath != null) {
167                        File file = getStorageFilename(storagePath, theResourceId, theBlobId);
168                        if (file.exists()) {
169                                inputStream = new FileInputStream(file);
170                        }
171                }
172                return inputStream;
173        }
174
175        @Override
176        public void expungeBinaryContent(IIdType theResourceId, String theBlobId) {
177                File storagePath = getStoragePath(theBlobId, false);
178                if (storagePath != null) {
179                        File storageFile = getStorageFilename(storagePath, theResourceId, theBlobId);
180                        if (storageFile.exists()) {
181                                delete(storageFile, theBlobId);
182                        }
183                        File descriptorFile = getDescriptorFilename(storagePath, theResourceId, theBlobId);
184                        if (descriptorFile.exists()) {
185                                delete(descriptorFile, theBlobId);
186                        }
187                }
188        }
189
190        @Override
191        public byte[] fetchBinaryContent(IIdType theResourceId, String theBlobId) throws IOException {
192                StoredDetails details = fetchBinaryContentDetails(theResourceId, theBlobId);
193                try (InputStream inputStream = getInputStream(theResourceId, theBlobId)) {
194
195                        if (inputStream != null) {
196                                return IOUtils.toByteArray(inputStream, details.getBytes());
197                        }
198                }
199
200                throw new ResourceNotFoundException(
201                                Msg.code(1327) + "Unknown blob ID: " + theBlobId + " for resource ID " + theResourceId);
202        }
203
204        private void delete(File theStorageFile, String theBlobId) {
205                Validate.isTrue(theStorageFile.delete(), "Failed to delete file for blob %s", theBlobId);
206        }
207
208        @Nonnull
209        private File getDescriptorFilename(File theStoragePath, IIdType theResourceId, String theId) {
210                return getStorageFilename(theStoragePath, theResourceId, theId, ".json");
211        }
212
213        @Nonnull
214        private File getStorageFilename(File theStoragePath, IIdType theResourceId, String theId) {
215                return getStorageFilename(theStoragePath, theResourceId, theId, ".bin");
216        }
217
218        private File getStorageFilename(File theStoragePath, IIdType theResourceId, String theId, String theExtension) {
219                Validate.notBlank(theResourceId.getResourceType());
220                Validate.notBlank(theResourceId.getIdPart());
221
222                String filename = theResourceId.getResourceType() + "_" + theResourceId.getIdPart() + "_" + theId;
223                return new File(theStoragePath, filename + theExtension);
224        }
225
226        private File getStoragePath(String theId, boolean theCreate) {
227                File path = myBasePath;
228                for (int i = 0; i < 10; i++) {
229                        path = new File(path, theId.substring(i, i + 1));
230                        if (!path.exists()) {
231                                if (theCreate) {
232                                        mkdir(path);
233                                } else {
234                                        return null;
235                                }
236                        }
237                }
238                return path;
239        }
240
241        private void mkdir(File theBasePath) {
242                try {
243                        FileUtils.forceMkdir(theBasePath);
244                } catch (IOException e) {
245                        throw new ConfigurationException(Msg.code(1328) + "Unable to create path " + myBasePath + ": " + e);
246                }
247        }
248}