diff --git a/docs/schemas/2024-10/donor-details.json b/docs/schemas/2024-10/donor-details.json index 457a79b9..414840ad 100644 --- a/docs/schemas/2024-10/donor-details.json +++ b/docs/schemas/2024-10/donor-details.json @@ -135,14 +135,31 @@ "type": "string", "enum": ["option-a", "option-b"] }, - "restrictionsAndConditions": { - "type": "string" - }, "signedAt": { "type": "string", "format": "date-time" } }, + "if": { + "required": ["channel"], + "properties": { + "channel": { "const": "online" } + } + }, + "then": { + "properties": { + "restrictionsAndConditions": { + "type": "string" + } + } + }, + "else": { + "properties": { + "restrictionsAndConditionsImages": { + "$ref": "#/$defs/Images" + } + } + }, "additionalProperties": false, "$defs": { "Address": { @@ -293,6 +310,23 @@ } ], "type": "object" + }, + "Images": { + "type": "array", + "items": { + "$ref": "#/$defs/ImageUpload" + } + }, + "ImageUpload": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "data": { + "type": "string" + } + } } } } diff --git a/fixtures/static/js/json-schema-editor.mjs b/fixtures/static/js/json-schema-editor.mjs index f9630d46..071b13d4 100644 --- a/fixtures/static/js/json-schema-editor.mjs +++ b/fixtures/static/js/json-schema-editor.mjs @@ -3,6 +3,14 @@ import { Tabs as GovukTabs } from "../govuk-frontend.min.js"; const { Draft07 } = window.jlib; +const toBase64 = (file) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result.split(";base64,")[1]); + reader.onerror = reject; + }); + export class JsonSchemaEditor { /** * @type {HTMLTextAreaElement} @@ -186,6 +194,38 @@ export class JsonSchemaEditor { $input.classList.add("govuk-input"); $parent.appendChild(this.createGovukFormGroup(nub, $input)); + } else if ( + schema.type === "object" && + Object.keys(schema.properties).join(",") === "filename,data" + ) { + const $filename = document.createElement("input"); + $filename.type = "hidden"; + $filename.name = `${pointer}/filename`; + const $data = document.createElement("input"); + $data.type = "hidden"; + $data.name = `${pointer}/data`; + + const $input = document.createElement("input"); + $input.id = `f-${pointer}`; + $input.type = "file"; + $input.classList.add("govuk-file-upload"); + + $input.addEventListener("change", async () => { + const file = $input.files?.[0]; + if (file) { + $filename.value = file.name; + $filename.dispatchEvent(new InputEvent("input", { bubbles: true })); + + $data.value = await toBase64(file); + $data.dispatchEvent(new InputEvent("input", { bubbles: true })); + } + }); + + $parent.appendChild($filename); + $parent.appendChild($data); + $parent.appendChild(this.createGovukFormGroup("Upload file", $input)); + + parents[pointer] = document.createElement("div"); } else if (schema.type === "object" || schema.type === "array") { const $details = document.createElement("details"); $details.classList.add("govuk-details"); diff --git a/internal/objectstore/client.go b/internal/objectstore/client.go index 799d8bf5..d033fff6 100644 --- a/internal/objectstore/client.go +++ b/internal/objectstore/client.go @@ -3,11 +3,15 @@ package objectstore import ( "bytes" "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" "encoding/json" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/ministryofjustice/opg-data-lpa-store/internal/shared" ) type awsS3Client interface { @@ -38,6 +42,38 @@ func (c *S3Client) Put(ctx context.Context, objectKey string, obj any) error { return err } +func (c *S3Client) UploadFile(ctx context.Context, image shared.FileUpload, path string) (shared.File, error) { + var imgData []byte + var err error + + if imgData, err = base64.StdEncoding.DecodeString(image.Data); err != nil { + return shared.File{}, err + } + + _, err = c.awsClient.PutObject( + ctx, + &s3.PutObjectInput{ + Bucket: aws.String(c.bucketName), + Key: aws.String(path), + Body: bytes.NewReader(imgData), + ServerSideEncryption: types.ServerSideEncryptionAwsKms, + }, + ) + + if err != nil { + return shared.File{}, err + } + + hash := sha256.New() + hash.Write(imgData) + + return shared.File{ + Path: path, + Hash: hex.EncodeToString(hash.Sum(nil)), + }, nil + +} + func NewS3Client(awsConfig aws.Config, bucketName string) *S3Client { awsClient := s3.NewFromConfig(awsConfig) diff --git a/internal/objectstore/client_test.go b/internal/objectstore/client_test.go index 550cbed5..1fbb73cc 100644 --- a/internal/objectstore/client_test.go +++ b/internal/objectstore/client_test.go @@ -1,10 +1,15 @@ package objectstore import ( + "bytes" "context" + "errors" "testing" + "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/ministryofjustice/opg-data-lpa-store/internal/shared" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -32,3 +37,75 @@ func TestPut(t *testing.T) { assert.Equal(t, nil, err) client.AssertExpectations(t) } + +func TestUploadFile(t *testing.T) { + upload := shared.FileUpload{ + Filename: "myfile.txt", + Data: "Q29udGVudHMgb2YgbXkgZmlsZQ==", + } + + client := mockAwsClient{} + client.On("PutObject", mock.Anything, &s3.PutObjectInput{ + Bucket: aws.String("bucket1"), + Key: aws.String("dir/myfile.txt"), + Body: bytes.NewReader([]byte("Contents of my file")), + ServerSideEncryption: types.ServerSideEncryptionAwsKms, + }).Return(&s3.PutObjectOutput{}, nil) + + c := S3Client{ + bucketName: "bucket1", + awsClient: &client, + } + + file, err := c.UploadFile(context.Background(), upload, "dir/myfile.txt") + + assert.Nil(t, err) + assert.Equal(t, "dir/myfile.txt", file.Path) + assert.Equal(t, "7ac4f2b48096ac5f4600a0775563d0f2b3369a3ea00d1fa813f45c18620dba28", file.Hash) + client.AssertExpectations(t) +} + +func TestUploadFileDecodingError(t *testing.T) { + upload := shared.FileUpload{ + Filename: "myfile.txt", + Data: "This isn't base 64", + } + + c := S3Client{ + bucketName: "bucket1", + awsClient: &mockAwsClient{}, + } + + file, err := c.UploadFile(context.Background(), upload, "dir/myfile.txt") + + assert.Equal(t, file, shared.File{}) + assert.Contains(t, err.Error(), "illegal base64 data") +} + +func TestUploadFileS3Error(t *testing.T) { + upload := shared.FileUpload{ + Filename: "myfile.txt", + Data: "Q29udGVudHMgb2YgbXkgZmlsZQ==", + } + + expectedErr := errors.New("could not save object") + + client := mockAwsClient{} + client.On("PutObject", mock.Anything, &s3.PutObjectInput{ + Bucket: aws.String("bucket1"), + Key: aws.String("dir/myfile.txt"), + Body: bytes.NewReader([]byte("Contents of my file")), + ServerSideEncryption: types.ServerSideEncryptionAwsKms, + }).Return(&s3.PutObjectOutput{}, expectedErr) + + c := S3Client{ + bucketName: "bucket1", + awsClient: &client, + } + + file, err := c.UploadFile(context.Background(), upload, "dir/myfile.txt") + + assert.Equal(t, file, shared.File{}) + assert.Equal(t, expectedErr, err) + client.AssertExpectations(t) +} diff --git a/internal/shared/image.go b/internal/shared/image.go new file mode 100644 index 00000000..e6aab778 --- /dev/null +++ b/internal/shared/image.go @@ -0,0 +1,11 @@ +package shared + +type File struct { + Path string `json:"path"` + Hash string `json:"hash"` +} + +type FileUpload struct { + Filename string `json:"filename"` + Data string `json:"data"` +} diff --git a/internal/shared/lpa.go b/internal/shared/lpa.go index 25836c7e..94cabdc1 100644 --- a/internal/shared/lpa.go +++ b/internal/shared/lpa.go @@ -21,16 +21,18 @@ type LpaInit struct { WhenTheLpaCanBeUsed CanUse `json:"whenTheLpaCanBeUsed,omitempty"` LifeSustainingTreatmentOption LifeSustainingTreatment `json:"lifeSustainingTreatmentOption,omitempty"` RestrictionsAndConditions string `json:"restrictionsAndConditions,omitempty"` + RestrictionsAndConditionsImages []FileUpload `json:"restrictionsAndConditionsImages,omitempty"` SignedAt time.Time `json:"signedAt"` CertificateProviderNotRelatedConfirmedAt *time.Time `json:"certificateProviderNotRelatedConfirmedAt,omitempty"` } type Lpa struct { LpaInit - Uid string `json:"uid"` - Status LpaStatus `json:"status"` - RegistrationDate *time.Time `json:"registrationDate,omitempty"` - UpdatedAt time.Time `json:"updatedAt"` + Uid string `json:"uid"` + Status LpaStatus `json:"status"` + RegistrationDate *time.Time `json:"registrationDate,omitempty"` + UpdatedAt time.Time `json:"updatedAt"` + RestrictionsAndConditionsImages []File `json:"restrictionsAndConditionsImages,omitempty"` } type LpaType string diff --git a/lambda/create/main.go b/lambda/create/main.go index c6e0c492..7485cbee 100644 --- a/lambda/create/main.go +++ b/lambda/create/main.go @@ -36,6 +36,7 @@ type Store interface { type S3Client interface { Put(ctx context.Context, objectKey string, obj any) error + UploadFile(ctx context.Context, image shared.FileUpload, path string) (shared.File, error) } type Verifier interface { @@ -104,6 +105,20 @@ func (l *Lambda) HandleEvent(ctx context.Context, req events.APIGatewayProxyRequ data.Status = shared.LpaStatusProcessing data.UpdatedAt = time.Now() + if data.Channel == shared.ChannelPaper && len(input.RestrictionsAndConditionsImages) > 0 { + data.RestrictionsAndConditionsImages = make([]shared.File, len(input.RestrictionsAndConditionsImages)) + for i, image := range input.RestrictionsAndConditionsImages { + path := fmt.Sprintf("%s/scans/rc_%d_%s", data.Uid, i, image.Filename) + + data.RestrictionsAndConditionsImages[i], err = l.staticLpaStorage.UploadFile(ctx, image, path) + + if err != nil { + l.logger.Error("error saving restrictions and conditions image", slog.Any("err", err)) + return shared.ProblemInternalServerError.Respond() + } + } + } + // save if err = l.store.Put(ctx, data); err != nil { l.logger.Error("error saving LPA", slog.Any("err", err))