package org.fluxtream.core.connectors.fluxtream_capture; import com.google.gson.Gson; import org.bodytrack.datastore.FilesystemKeyValueStore; import org.fluxtream.core.Configuration; import org.fluxtream.core.aspects.FlxLogger; import org.fluxtream.core.connectors.Connector; import org.fluxtream.core.connectors.ObjectType; import org.fluxtream.core.domain.ApiKey; import org.fluxtream.core.domain.ChannelMapping; import org.fluxtream.core.images.ImageType; import org.fluxtream.core.services.ApiDataService; import org.fluxtream.core.services.BodyTrackStorageService; import org.fluxtream.core.services.GuestService; import org.fluxtream.core.services.JPADaoService; import org.fluxtream.core.utils.ImageUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.io.File; import java.util.Arrays; /** * <p> * <code>FluxtreamCapturePhotoStore</code> enables managment of Fluxtream Capture photos. * </p> * * @author Chris Bartley (bartley@cmu.edu) */ @Component public final class FluxtreamCapturePhotoStore { private static final FlxLogger LOG = FlxLogger.getLogger(FluxtreamCapturePhotoStore.class); private static final FlxLogger LOG_DEBUG = FlxLogger.getLogger("Fluxtream"); @Autowired BodyTrackStorageService bodyTrackStorageService; @Autowired GuestService guestService; public enum Operation { CREATED("created"), UPDATED("updated"); private final String name; Operation(final String name) { this.name = name; } public String getName() { return name; } @Override public String toString() { return name; } } public interface OperationResult<T> { @NotNull Operation getOperation(); @Nullable Long getDatabaseRecordId(); @NotNull T getData(); } public interface Photo { byte[] getPhotoBytes(); /** * Returns the timestamp, in millis, that the photo was last updated. Returns <code>null</code> if unknown. */ @Nullable Long getLastUpdatedTimestamp(); /** * A {@link String} representation of the unique identifier for this photo. Useful for logging and messages. */ @NotNull String getIdentifier(); /** * Returns the {@link ImageType} for this photo. */ @NotNull ImageType getImageType(); } @Autowired private ApiDataService apiDataService; @Autowired protected JPADaoService jpaDaoService; @Autowired private Configuration env; private final Gson gson = new Gson(); private FluxtreamCapturePhotoStore() { // private to prevent instantiation } public boolean deletePhoto(final String photoStoreKey) throws StorageException { return getFilesystemKeyValueStore().delete(photoStoreKey); } /** * Returns the photo specified by the given <code>photoStoreKey</code> or <code>null</code> if no such photo exists. * This method assumes that the caller has already performed authentication and authorization. */ @Nullable public Photo getPhoto(@Nullable final String photoStoreKey) throws StorageException { if (photoStoreKey != null) { final byte[] bytes = getFilesystemKeyValueStore().get(photoStoreKey); if (bytes != null) { return new Photo() { @Override public byte[] getPhotoBytes() { return bytes; } @Override public Long getLastUpdatedTimestamp() { return null; } @NotNull @Override public String getIdentifier() { return photoStoreKey; } @NotNull @Override public ImageType getImageType() { // Try to read the image type. If we can't for some reason, then just lie and say it's a JPEG. // This really should never happen, but it's good to check for it anyway and log a warning if it // happens. ImageType imageType = ImageUtils.getImageType(bytes); if (imageType == null) { imageType = ImageType.JPEG; LOG.warn("FluxtreamCapturePhotoStore.getImageType(): Could not determine the media type for photo [" + getIdentifier() + "]! Defaulting to [" + imageType.getMediaType() + "]"); } return imageType; } }; } } return null; } /** * Returns the photo thumbnail specified by the given <code>photoId</code> or <code>null</code> if no such photo * exists. This method assumes that the caller has already performed authentication and authorization. */ @Nullable public Photo getPhotoThumbnail(final long uid, final long photoId, final int thumbnailIndex) { final FluxtreamCapturePhotoFacet photoFacet = jpaDaoService.findOne("fluxtream_capture.photo.byId", FluxtreamCapturePhotoFacet.class, uid, photoId); if (photoFacet != null) { return new Photo() { @Override public byte[] getPhotoBytes() { return photoFacet.getThumbnail(thumbnailIndex); } @Override public Long getLastUpdatedTimestamp() { return photoFacet.timeUpdated; } @NotNull @Override public String getIdentifier() { return photoId + "/" + thumbnailIndex; } @NotNull @Override public ImageType getImageType() { return ImageType.JPEG; // thumbnails are always JPEGs } }; } return null; } /** * Saves the given photo for the given user to both the database and the Fluxtream Capture key-value photo store. * Returns an {@link OperationResult} if the photo was created or updated (see the * {@link OperationResult#getOperation()} method to determine which occurred), or throws a * {@link FluxtreamCapturePhotoStoreException} exception if the save/update failed. * * @throws InvalidDataException if the photo or its metadata is <code>null</code>, empty, or in some way invalid * @throws StorageException if the save/update fails to either the photo key-value store or the database * @throws UnsupportedImageFormatException if the save/update fails because the given image is not of a supported format */ @SuppressWarnings("ConstantConditions") public OperationResult<FluxtreamCapturePhoto> saveOrUpdatePhoto(final long guestId, @NotNull final byte[] photoBytes, @NotNull final String jsonMetadata, final Long apiKeyId) throws StorageException, InvalidDataException, UnsupportedImageFormatException { if (LOG_DEBUG.isDebugEnabled()) { LOG_DEBUG.debug("FluxtreamCapturePhotoStore.savePhoto(" + guestId + ", " + photoBytes.length + ", " + jsonMetadata + ")"); } // do simple null and empty validation if (photoBytes == null || photoBytes.length <= 0 || jsonMetadata == null || jsonMetadata.length() <= 0) { final String message = "Photo upload failed because the byte array and JSON metadata for the photo must both be non-null and non-empty"; LOG.error("FluxtreamCapturePhotoStore.saveOrUpdatePhoto(): " + message); throw new InvalidDataException(message); } // Go ahead and try to create the FilesystemKeyValueStore. This is a simple operation, so, if it's going // to fail, then it's better to fail now rather than after spending a lot of effort creating the photo hash // and thumbnails. final FilesystemKeyValueStore keyValueStore = getFilesystemKeyValueStore(); // Attempt to parse the JSON metadata final FluxtreamCapturePhoto.PhotoUploadMetadata metadata; try { metadata = gson.fromJson(jsonMetadata, FluxtreamCapturePhoto.PhotoUploadMetadata.class); } catch (Exception e) { final String message = "Photo upload failed because an Exception occurred while trying to parse the photo metadata"; LOG.error("FluxtreamCapturePhotoStore.saveOrUpdatePhoto(): " + message); throw new InvalidDataException(message); } // Validate the JSON metadata if (metadata == null || !metadata.isValid()) { final String message = "Photo upload failed because the JSON metadata is null or invalid"; LOG.error("FluxtreamCapturePhotoStore.saveOrUpdatePhoto(): " + message); throw new InvalidDataException(message); } // Create the FluxtreamCapturePhoto (this validates the photo, generates the hash and thumbnails, etc.) final FluxtreamCapturePhoto photo; try { photo = new FluxtreamCapturePhoto(guestId, photoBytes, metadata); } catch (UnsupportedImageFormatException e) { LOG.error("FluxtreamCapturePhotoStore.saveOrUpdatePhoto(): Photo upload failed because an UnsupportedOperationException occurred while trying to create the FluxtreamCapturePhoto"); throw e; } catch (Exception e) { final String message = "Photo upload failed because an Exception occurred while trying to create the FluxtreamCapturePhoto"; LOG.error("FluxtreamCapturePhotoStore.saveOrUpdatePhoto(): " + message, e); throw new InvalidDataException(message, e); } // Now that we have the key-value store created and everything appears to be valid, we can go ahead and // insert the photo into the photo key-value store, but only if the key doesn't already exist final String photoStoreKey = photo.getPhotoStoreKey(); if (!keyValueStore.hasKey(photoStoreKey)) { if (!keyValueStore.set(photoStoreKey, photo.getPhotoBytes())) { final String message = "Photo upload failed because the photo could not be saved to the key-value store"; LOG.error("FluxtreamCapturePhotoStore.saveOrUpdatePhoto(): " + message); throw new StorageException(message); } } // The photo is in the key-value store, so try to save or update to the DB final PhotoCreatorOrModifier photoCreatorOrModifier = new PhotoCreatorOrModifier(photo); final FluxtreamCapturePhotoFacet photoFacet; try { photoFacet = apiDataService.createOrReadModifyWrite(FluxtreamCapturePhotoFacet.class, photoCreatorOrModifier.getFacetFinderQuery(), photoCreatorOrModifier, apiKeyId); } catch (Exception e) { final String message = "Photo upload failed because an Exception occurred while writing the photo to the database"; LOG.error("FluxtreamCapturePhotoStore.saveOrUpdatePhoto(): " + message, e); throw new StorageException(message, e); } if (photoFacet == null) { // attempt to remove the photo from the key-value store keyValueStore.delete(photoStoreKey); final String message = "Upload failed because the ApiDataService failed to save the facet and returned null"; LOG.error("FluxtreamCapturePhotoStore.saveOrUpdatePhoto(): " + message); throw new StorageException(message); } // make sure that we have a proper ChannelMapping for photos try { final ApiKey apiKey = guestService.getApiKey(apiKeyId); bodyTrackStorageService.ensurePhotoChannelMappingsExist(apiKey, Arrays.asList("photo"), "FluxtreamCapture", ObjectType.getObjectType(Connector.getConnector("fluxtream_capture"), "photo").value()); } catch (Exception e) { final String message = "Photo upload failed because an Exception occurred while writing the photo to the database"; LOG.error("FluxtreamCapturePhotoStore.saveOrUpdatePhoto(): " + message, e); throw new StorageException(message, e); } // If we got this far, then we know everything succeeded, so simply return the boolean to indicate whether // the photo was created or updated if (LOG_DEBUG.isInfoEnabled() || LOG.isInfoEnabled()) { final String message = "FluxtreamCapturePhotoStore.saveOrUpdatePhoto(): photo [" + photoFacet.getHash() + "] " + (photoCreatorOrModifier.wasCreated() ? "saved" : "updated") + " sucessfully for user [" + guestId + "]"; LOG.info(message); LOG_DEBUG.info(message); } return new OperationResult<FluxtreamCapturePhoto>() { @NotNull @Override public Operation getOperation() { return photoCreatorOrModifier.wasCreated() ? Operation.CREATED : Operation.UPDATED; } @Nullable @Override public Long getDatabaseRecordId() { return photoFacet.getId(); } @NotNull @Override public FluxtreamCapturePhoto getData() { return photo; } }; } @NotNull private FilesystemKeyValueStore getFilesystemKeyValueStore() throws StorageException { try { final File keyValueStoreLocation = new File(env.targetEnvironmentProps.getString("btdatastore.db.location")); return new FilesystemKeyValueStore(keyValueStoreLocation); } catch (IllegalArgumentException e) { final String message = "The photo key-value store could not be created"; LOG.error("FluxtreamCapturePhotoStore.getFilesystemKeyValueStore(): " + message, e); throw new StorageException(message, e); } } private static final class PhotoCreatorOrModifier implements ApiDataService.FacetModifier<FluxtreamCapturePhotoFacet> { @NotNull private final ApiDataService.FacetQuery facetFinderQuery; @NotNull private final FluxtreamCapturePhoto photo; private boolean wasCreated; public PhotoCreatorOrModifier(@NotNull final FluxtreamCapturePhoto photo) { this.photo = photo; facetFinderQuery = new ApiDataService.FacetQuery("e.guestId = ? AND e.hash = ? AND e.start = ?", photo.getGuestId(), photo.getPhotoHash(), photo.getCaptureTimeMillisUtc()); } @Override public FluxtreamCapturePhotoFacet createOrModify(final FluxtreamCapturePhotoFacet existingFacet, final Long apiKeyId) { if (existingFacet == null) { wasCreated = true; return new FluxtreamCapturePhotoFacet(photo, apiKeyId); } else { wasCreated = false; // We already have this photo, so we don't need to do anything other than update the timeUpdated field. // We ignore the comments and tags fields here because the client should use the metadata set method // instead. existingFacet.timeUpdated = System.currentTimeMillis(); return existingFacet; } } public boolean wasCreated() { return wasCreated; } @NotNull public ApiDataService.FacetQuery getFacetFinderQuery() { return facetFinderQuery; } } public static abstract class FluxtreamCapturePhotoStoreException extends Exception { protected FluxtreamCapturePhotoStoreException(final String s) { super(s); } protected FluxtreamCapturePhotoStoreException(final String s, final Throwable throwable) { super(s, throwable); } } public static class StorageException extends FluxtreamCapturePhotoStoreException { protected StorageException(final String s) { super(s); } protected StorageException(final String s, final Throwable throwable) { super(s, throwable); } } public static final class InvalidDataException extends FluxtreamCapturePhotoStoreException { protected InvalidDataException(final String s) { super(s); } protected InvalidDataException(final String s, final Throwable throwable) { super(s, throwable); } } public static final class UnsupportedImageFormatException extends FluxtreamCapturePhotoStoreException { protected UnsupportedImageFormatException(final String s) { super(s); } protected UnsupportedImageFormatException(final String s, final Throwable throwable) { super(s, throwable); } } }