From a430bd97d1697da2f4e46cd931e8b563464c94d5 Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Tue, 28 Nov 2023 21:47:16 -0600 Subject: [PATCH] WIP, beginning work on hash implementation. --- adr/2023-11-26-shortform-data-model.md | 7 +-- .../scala/gs/shortform/crypto/Sha256.scala | 45 +++++++++++++++ .../main/scala/gs/shortform/model/Asset.scala | 33 +++++++++++ .../scala/gs/shortform/model/ModelError.scala | 25 ++++++++ .../main/scala/gs/shortform/model/Post.scala | 4 +- .../main/scala/gs/shortform/model/Tag.scala | 57 +++++++++++++++++++ .../scala/gs/shortform/model/Username.scala | 5 +- 7 files changed, 169 insertions(+), 7 deletions(-) create mode 100644 modules/crypto/src/main/scala/gs/shortform/crypto/Sha256.scala create mode 100644 modules/model/src/main/scala/gs/shortform/model/Asset.scala create mode 100644 modules/model/src/main/scala/gs/shortform/model/ModelError.scala create mode 100644 modules/model/src/main/scala/gs/shortform/model/Tag.scala diff --git a/adr/2023-11-26-shortform-data-model.md b/adr/2023-11-26-shortform-data-model.md index df95923..dac1c65 100644 --- a/adr/2023-11-26-shortform-data-model.md +++ b/adr/2023-11-26-shortform-data-model.md @@ -77,12 +77,11 @@ but ShortForm imposes a configurable limit (10kb by default). CREATE TABLE assets( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, external_id UUID NOT NULL UNIQUE, - created_at TIMESTAMPTZ NOT NULL, - created_by BIGINT NOT NULL, - mime TEXT NOT NULL, title TEXT NOT NULL, extension TEXT NOT NULL, - hash TEXT NOT NULL + hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + created_by BIGINT NOT NULL ); ``` diff --git a/modules/crypto/src/main/scala/gs/shortform/crypto/Sha256.scala b/modules/crypto/src/main/scala/gs/shortform/crypto/Sha256.scala new file mode 100644 index 0000000..2b5eb94 --- /dev/null +++ b/modules/crypto/src/main/scala/gs/shortform/crypto/Sha256.scala @@ -0,0 +1,45 @@ +package gs.shortform.crypto + +import java.security.MessageDigest +import java.io.BufferedInputStream + +object Sha256: + /** + * Calculate the SHA-256 hash for some data. + * + * @param data The data to hash. + * @return The base64-encoded [[Hash]] value. + */ + def calculateHash(data: Array[Byte]): Hash = + val digest: MessageDigest = MessageDigest.getInstance(Sha256.Algorithm) + Hash.encode(digest.digest(data)) + + /** + * Consume the entire given stream and calculate its SHA-256 hash. This + * function _always_ closes the underlying stream. + * + * @param data The data to hash. + * @return The base64-encoded [[Hash]] value. + */ + def consumeToHash(data: BufferedInputStream): Hash = + try + // TODO: Need sum catz? + val digest: MessageDigest = MessageDigest.getInstance(Sha256.Algorithm) + val buffer: Array[Byte] = Array.ofDim[Byte](8192) + var count: Int = data.read(buffer) + while + count > 0 + do + digest.update(buffer, 0, count) + count = data.read(buffer) + + Hash.encode(digest.digest()) + finally + data.close() + + /** + * JCA Algorithm name for SHA-256. + */ + val Algorithm: String = "SHA-256" + +end Sha256 diff --git a/modules/model/src/main/scala/gs/shortform/model/Asset.scala b/modules/model/src/main/scala/gs/shortform/model/Asset.scala new file mode 100644 index 0000000..04b02cd --- /dev/null +++ b/modules/model/src/main/scala/gs/shortform/model/Asset.scala @@ -0,0 +1,33 @@ +package gs.shortform.model + +import gs.uuid.UUID +import gs.shortform.crypto.Hash + +case class Asset( + externalId: UUID, + title: Title, + extension: Asset.Extension, + hash: Hash, + createdAt: CreatedAt, + createdBy: Username +) + +object Asset: + + /** + * Represents a file extension for some [[Asset]]. + */ + opaque type Extension = String + + object Extension: + + def apply(value: String): Extension = value + + given CanEqual[Extension, Extension] = CanEqual.derived + + extension (ext: Extension) + def str(): String = ext + + end Extension + +end Asset diff --git a/modules/model/src/main/scala/gs/shortform/model/ModelError.scala b/modules/model/src/main/scala/gs/shortform/model/ModelError.scala new file mode 100644 index 0000000..b718f9d --- /dev/null +++ b/modules/model/src/main/scala/gs/shortform/model/ModelError.scala @@ -0,0 +1,25 @@ +package gs.shortform.model + +import gs.shortform.error.ShortFormError + +sealed trait ModelError extends ShortFormError + +object ModelError: + + /** + * Results from a [[Tag]] failing validation for being too short. + * + * @param candidate The candidate string. + * @param minimumLength The minimum required length. + */ + case class TagTooShort(candidate: String, minimumLength: Int) extends ModelError + + /** + * Results from a [[Tag]] failing validation for being too long. + * + * @param candidate The candidate string. + * @param maximumLength The maximum required length. + */ + case class TagTooLong(candidate: String, maximumLength: Int) extends ModelError + +end ModelError diff --git a/modules/model/src/main/scala/gs/shortform/model/Post.scala b/modules/model/src/main/scala/gs/shortform/model/Post.scala index 5fc30d6..e1febac 100644 --- a/modules/model/src/main/scala/gs/shortform/model/Post.scala +++ b/modules/model/src/main/scala/gs/shortform/model/Post.scala @@ -15,11 +15,13 @@ import gs.shortform.crypto.Hash * @param createdBy User who created this post. * @param title Display title for this post. * @param hash Hash of the primary rendered file for this post. + * @param tags List of [[Tag]] applied to this post. */ case class Post( externalId: UUID, createdAt: CreatedAt, createdBy: Username, title: Title, - hash: Hash + hash: Hash, + tags: List[Tag] ) diff --git a/modules/model/src/main/scala/gs/shortform/model/Tag.scala b/modules/model/src/main/scala/gs/shortform/model/Tag.scala new file mode 100644 index 0000000..2d19c7c --- /dev/null +++ b/modules/model/src/main/scala/gs/shortform/model/Tag.scala @@ -0,0 +1,57 @@ +package gs.shortform.model + +import gs.shortform.error.ShortFormError + +/** + * Arbitrary short string which can be used to annotate [[Post]]s. + */ +opaque type Tag = String + +object Tag: + + object Constraints: + + /** + * The minimum allowed length for tags. + */ + val MinimumLength: Int = 1 + + /** + * The maximum allowed length for tags. + */ + val MaximumLength: Int = 32 + + end Constraints + + /** + * Instantiate a new [[Tag]]. This function is unsafe. + * + * @param value The tag value. + * @return The new tag. + */ + def apply(value: String): Tag = value + + /** + * Validate some candidate value to instantiate a new [[Tag]]. + * + * @param value The candidate string value. + * @return The new tag, or an error if the input was invalid. + */ + def validate(value: String): Either[ShortFormError, Tag] = + if value.length() < Constraints.MinimumLength then + Left(ModelError.TagTooShort(value, Constraints.MinimumLength)) + else if value.length() > Constraints.MaximumLength then + Left(ModelError.TagTooLong(value, Constraints.MaximumLength)) + else + Right(value) + + given CanEqual[Tag, Tag] = CanEqual.derived + + extension (title: Tag) + /** + * Render this [[Tag]] as a string. + */ + def str(): String = title + +end Tag + diff --git a/modules/model/src/main/scala/gs/shortform/model/Username.scala b/modules/model/src/main/scala/gs/shortform/model/Username.scala index 3c8a32e..07a2e7d 100644 --- a/modules/model/src/main/scala/gs/shortform/model/Username.scala +++ b/modules/model/src/main/scala/gs/shortform/model/Username.scala @@ -12,8 +12,9 @@ object Username: given CanEqual[Username, Username] = CanEqual.derived extension (title: Username) + /** + * Render this [[Username]] as a string. + */ def str(): String = title end Username - -