diff --git a/.golangci.yml b/.golangci.yml index 6d9f179..a060419 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,3 +1,4 @@ +version: "2" run: timeout: 2m linters: diff --git a/docstore/s3/s3.go b/docstore/s3/s3.go index afa931d..2410f9c 100644 --- a/docstore/s3/s3.go +++ b/docstore/s3/s3.go @@ -16,6 +16,7 @@ import ( "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/s3" "github.com/luthersystems/svc/docstore" + "github.com/sirupsen/logrus" ) type missingRetryer struct { @@ -133,7 +134,11 @@ func (a *Store) GetStreaming(key string, w http.ResponseWriter) error { w.Header().Set("Connection", "close") w.Header().Set("Content-Type", *(result.ContentType)) w.Header().Set("Content-Length", fmt.Sprintf("%d", *(result.ContentLength))) - defer result.Body.Close() + defer func() { + if err := result.Body.Close(); err != nil { + logrus.WithError(err).Warn("get streaming: close") + } + }() _, err := io.Copy(w, result.Body) if err != nil { return fmt.Errorf("s3 get: %w", err) diff --git a/go.mod b/go.mod index 748562b..a7deb91 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,6 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 google.golang.org/grpc v1.65.0 google.golang.org/protobuf v1.36.5 - gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -91,4 +90,5 @@ require ( golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240812133136-8ffd90a71988 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/mailer/mailer.go b/mailer/mailer.go index b81ea57..a928c1f 100644 --- a/mailer/mailer.go +++ b/mailer/mailer.go @@ -1,9 +1,13 @@ -// Copyright © 2021 Luther Systems, Ltd. All right reserved. +// Copyright © 2025 Luther Systems, Ltd. All right reserved. package mailer import ( + "bytes" "context" + "fmt" + "mime/multipart" + "net/textproto" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" @@ -67,3 +71,66 @@ func (m *SES) Send(ctx context.Context, content string, email string, subject st } return nil } + +// Attachment represents a file to attach to the email. +type Attachment struct { + Filename string + Data []byte +} + +// SendWithAttachment sends an email with one or more attachments. +func (m *SES) SendWithAttachment(ctx context.Context, body, to, subject string, attachments []Attachment) error { + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + // Set email headers + mimeHeaders := make(map[string]string) + mimeHeaders["From"] = m.sender + mimeHeaders["To"] = to + mimeHeaders["Subject"] = subject + mimeHeaders["MIME-Version"] = "1.0" + mimeHeaders["Content-Type"] = "multipart/mixed; boundary=" + writer.Boundary() + + // Write email headers + var msg bytes.Buffer + for k, v := range mimeHeaders { + msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v)) + } + msg.WriteString("\r\n") + + // Write HTML body part + bodyWriter, _ := writer.CreatePart(textproto.MIMEHeader{ + "Content-Type": {"text/html; charset=utf-8"}, + }) + if _, err := bodyWriter.Write([]byte(body)); err != nil { + return fmt.Errorf("write: %w", err) + } + + // Attach files + for _, att := range attachments { + partHeader := textproto.MIMEHeader{} + partHeader.Set("Content-Type", "application/zip") + partHeader.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, att.Filename)) + part, _ := writer.CreatePart(partHeader) + if _, err := part.Write(att.Data); err != nil { + return fmt.Errorf("write: %w", err) + } + } + + if err := writer.Close(); err != nil { + return fmt.Errorf("close: %w", err) + } + + if _, err := msg.Write(buf.Bytes()); err != nil { + return fmt.Errorf("write: %w", err) + } + + input := &ses.SendRawEmailInput{ + RawMessage: &ses.RawMessage{ + Data: msg.Bytes(), + }, + } + + _, err := m.svc.SendRawEmailWithContext(ctx, input) + return err +} diff --git a/mailer/mailer_test.go b/mailer/mailer_test.go index 1965063..4afc178 100644 --- a/mailer/mailer_test.go +++ b/mailer/mailer_test.go @@ -1,8 +1,10 @@ -// Copyright © 2021 Luther Systems, Ltd. All right reserved. +// Copyright © 2025 Luther Systems, Ltd. All right reserved. package mailer import ( + "archive/zip" + "bytes" "context" "os" "testing" @@ -59,3 +61,46 @@ func TestSend(t *testing.T) { } t.Logf("Sent email to: %s", recipient) } + +// TestSendWithAttachment sends an email with a zip attachment. +func TestSendWithAttachment(t *testing.T) { + if os.Getenv("MAILER_SES_TESTS") == "" { + t.Skip("Skipping test: $MAILER_SES_TESTS not set") + } + recipient := DefaultSuccessEmail + if os.Getenv("MAILER_SES_RECIPIENT") != "" { + recipient = os.Getenv("MAILER_SES_RECIPIENT") + } + mailer, err := NewSES(SESRegion, EmailSender) + if err != nil { + t.Fatalf("init mailer: %v", err) + } + ctx, done := context.WithTimeout(context.Background(), reqTimeout) + defer done() + + // Create a zip archive containing a file with "hello world" + var buf bytes.Buffer + zipWriter := zip.NewWriter(&buf) + fileWriter, err := zipWriter.Create("hello.txt") + if err != nil { + t.Fatalf("create zip entry: %v", err) + } + _, err = fileWriter.Write([]byte("hello world")) + if err != nil { + t.Fatalf("write zip entry: %v", err) + } + if err := zipWriter.Close(); err != nil { + t.Fatalf("close zip writer: %v", err) + } + + attachment := Attachment{ + Filename: "testdata.zip", + Data: buf.Bytes(), + } + + err = mailer.SendWithAttachment(ctx, HTMLTemplateText, recipient, SubjectTemplateText+" With Attachment", []Attachment{attachment}) + if err != nil { + t.Fatalf("send mailer with attachment: %v", err) + } + t.Logf("Sent email with attachment to: %s", recipient) +} diff --git a/midware/interface_test.go b/midware/interface_test.go index 5e9b0d3..8e824e4 100644 --- a/midware/interface_test.go +++ b/midware/interface_test.go @@ -95,7 +95,11 @@ func testRequest(t *testing.T, server *httptest.Server, method string, rpath str } resp, err := (&http.Client{}).Do(r) require.NoError(t, err, "request failure") - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + require.NoError(t, err, "close") + } + }() b, err := io.ReadAll(resp.Body) require.NoError(t, err, "unable to read response") return b @@ -112,7 +116,11 @@ func testResponseHeaders(t *testing.T, server *httptest.Server, method string, r } resp, err := (&http.Client{}).Do(r) require.NoError(t, err, "request failure") - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + require.NoError(t, err, "close") + } + }() return resp } diff --git a/oracle/config.go b/oracle/config.go index c2d071a..0a132f7 100644 --- a/oracle/config.go +++ b/oracle/config.go @@ -74,6 +74,8 @@ type Config struct { publicContentHandlers *http.ServeMux // publicContentPathPrefix configures endpoint to serve public content. publicContentPathPrefix string + // DiscardUnknown will ignore unknown fields (not throw an error). + DiscardUnknown bool `yaml:"discard-unknown"` } const ( diff --git a/oracle/deptx_test.go b/oracle/deptx_test.go index d838df9..2dc2bd6 100644 --- a/oracle/deptx_test.go +++ b/oracle/deptx_test.go @@ -83,7 +83,9 @@ func TestOracleDepTx(t *testing.T) { var out2 hellov1.UseDepTxResponse err = json.NewDecoder(secondResp.Body).Decode(&out2) require.NoError(t, err) - secondResp.Body.Close() + if err := secondResp.Body.Close(); err != nil { + require.NoError(t, err) + } t.Logf("Second call: old=%s new=%s", out2.GetOldTxId(), out2.GetNewTxId()) require.Equal(t, out1.GetNewTxId(), out2.GetOldTxId(), "the oldTx should match the first call's newTx") diff --git a/oracle/oracle.go b/oracle/oracle.go index d14b6f2..c82fadf 100644 --- a/oracle/oracle.go +++ b/oracle/oracle.go @@ -276,13 +276,18 @@ func (orc *Oracle) phylumHealthCheck(ctx context.Context) []*healthcheck.HealthC Status: "DOWN", }} } + found := false reports := ccHealth.GetReports() for _, report := range reports { if strings.EqualFold(report.GetServiceName(), orc.cfg.PhylumServiceName) { orc.setPhylumVersion(report.GetServiceVersion()) + found = true break } } + if !found { + orc.Log(ctx).WithField("service_name", orc.cfg.PhylumServiceName).Debug("no phylum version found") + } return reports } diff --git a/oracle/oracle_test.go b/oracle/oracle_test.go index 13bb830..e8c277b 100644 --- a/oracle/oracle_test.go +++ b/oracle/oracle_test.go @@ -152,7 +152,11 @@ func TestCookieAndHeaderForwarders(t *testing.T) { reqBody := bytes.NewBufferString(`{"name": "Bob"}`) resp, err := http.Post("http://"+gwLis.Addr().String()+"/v1/hello", "application/json", reqBody) require.NoError(t, err) - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + require.NoError(t, err) + } + }() // Should be 200 OK require.Equal(t, http.StatusOK, resp.StatusCode) diff --git a/oracle/oraclerun.go b/oracle/oraclerun.go index c40aecc..00e035c 100644 --- a/oracle/oraclerun.go +++ b/oracle/oraclerun.go @@ -84,7 +84,7 @@ func (orc *Oracle) grpcGatewayMux() *runtime.ServeMux { UseProtoNames: true, }, UnmarshalOptions: protojson.UnmarshalOptions{ - DiscardUnknown: false, + DiscardUnknown: orc.cfg.DiscardUnknown, }, }), } diff --git a/oracle/oracletester.go b/oracle/oracletester.go index 0841a7b..93e69e5 100644 --- a/oracle/oracletester.go +++ b/oracle/oracletester.go @@ -74,7 +74,11 @@ func getFreeAddr() (string, error) { if err != nil { return "", fmt.Errorf("failed to get a free port: %w", err) } - defer l.Close() // Close immediately so it can be reused + defer func() { + if err := l.Close(); err != nil { // Close immediately so it can be reused + logrus.WithError(err).Warn("getFreeAddr: close") + } + }() return l.Addr().String(), nil }