diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..a8a206ad2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,31 @@ +* text=auto + +*.go text +*.sh text +*.html text +*.js text +*.jsx text +*.ts text +*.tsx text +*.md text +*.json text +*.svg text +*.css text +*.yaml text +*.yml text +*.txt text +*.tmpl text +*.service text +Dockerfile text +Dockerfile.* text +LICENSE text + +*.jpg binary +*.png binary +*.gif binary +*.ico binary + +# RFC2822 requires us to save the email templates with CRLF line endings. +internal/templates/src/notification/* text eol=crlf +examples/templates/notifications/*.tmpl text eol=crlf +examples/templates/notifications/*.html text eol=crlf diff --git a/docs/content/en/reference/guides/notification-templates.md b/docs/content/en/reference/guides/notification-templates.md index fe11a1326..42ab8f871 100644 --- a/docs/content/en/reference/guides/notification-templates.md +++ b/docs/content/en/reference/guides/notification-templates.md @@ -18,6 +18,17 @@ two extensions; `.html` for HTML templates, and `.txt` for plaintext templates. This guide effectively documents the usage of the [template_path](../../configuration/notifications/introduction.md#template_path) notification configuration option. +## Important Notes + +1. The templates are not covered by our stability guarantees. While we aim to avoid changes to the templates which + would cause users to have to manually change them changes may be necessary in order to facilitate bug fixes or + generally improve the templates. + 1. This is especially important for the [Envelope Template](#envelope-template). + 2. It is your responsibility to ensure your templates are up to date. We make no efforts in facilitating this. +2. We may not be able to offer any direct support in debugging these templates. We only offer support and fixes to + the official templates. +3. All templates __*MUST*__ be encoded in UTF-8 with CRLF line endings. The line endings __*MUST NOT*__ be a simple LF. + ## Template Names | Template | Description | @@ -60,13 +71,14 @@ Some Additional examples for specific purposes can be found in the ## Envelope Template +*__Important Note:__ This template must end with a CRLF newline. Failure to include this newline will result in +malformed emails.* + There is also a special envelope template. This is the email envelope which contains the content of the other templates when sent via the SMTP notifier. It's *__strongly recommended__* that you do not modify this template unless you know what you're doing. If you really want to modify it the name of the file must be `Envelope.tmpl`. -This template contains the following placeholders: - -In template files, you can use the following placeholders which are automatically injected into the templates: +This template contains the following placeholders which are automatically injected into the template: | Placeholder | Description | |:-----------------------:|:---------------------------------------------------------------------------:| @@ -80,9 +92,6 @@ In template files, you can use the following placeholders which are automaticall | `{{ .To }}` | The string representation of the recipients email address. | | `{{ .Subject }}` | The email subject. | | `{{ .Date }}` | The time.Time of the email envelope being rendered. | -| `{{ .Boundary }}` | The random alphanumeric 20 character multi-part email boundary. | -| `{{ .Body.PlainText }}` | The plain text version of the email. | -| `{{ .Body.HTML }}` | The HTML version of the email. | ## Original Templates diff --git a/examples/templates/notifications/envelope_with_message_id.tmpl b/examples/templates/notifications/envelope_with_message_id.tmpl index 3a05ad8ba..774bac8ca 100644 --- a/examples/templates/notifications/envelope_with_message_id.tmpl +++ b/examples/templates/notifications/envelope_with_message_id.tmpl @@ -4,18 +4,3 @@ Subject: {{ .Subject }} Date: {{ .Date.Format "Mon, 2 Jan 2006 15:04:05 -0700" }} Message-ID: <{{ .UUID }}@{{ .Identifier }}> MIME-Version: 1.0 -Content-Type: multipart/alternative; boundary="{{ .Boundary }}" - ---{{ .Boundary }} -Content-Type: text/plain; charset="utf-8" -Content-Transfer-Encoding: quoted-printable -Content-Disposition: inline - -{{ .Body.PlainText }} -{{- if not (eq .Body.HTML "") }} ---{{ .Boundary }} -Content-Type: text/html; charset="utf-8" - -{{ .Body.HTML }} -{{- end }} ---{{ .Boundary }}-- diff --git a/internal/duo/duo.go b/internal/duo/duo.go index bcfaa15a5..345190904 100644 --- a/internal/duo/duo.go +++ b/internal/duo/duo.go @@ -11,10 +11,9 @@ import ( // NewDuoAPI create duo API instance. func NewDuoAPI(duoAPI *duoapi.DuoApi) *APIImpl { - api := new(APIImpl) - api.DuoApi = duoAPI - - return api + return &APIImpl{ + DuoApi: duoAPI, + } } // Call call to the DuoAPI. diff --git a/internal/handlers/handler_reset_password_step2.go b/internal/handlers/handler_reset_password_step2.go index 4cdf0c976..213a7305e 100644 --- a/internal/handlers/handler_reset_password_step2.go +++ b/internal/handlers/handler_reset_password_step2.go @@ -74,8 +74,6 @@ func ResetPasswordPOST(ctx *middlewares.AutheliaCtx) { return } - bufHTML := new(bytes.Buffer) - disableHTML := false if ctx.Configuration.Notifier.SMTP != nil { disableHTML = ctx.Configuration.Notifier.SMTP.DisableHTMLEmails @@ -87,6 +85,8 @@ func ResetPasswordPOST(ctx *middlewares.AutheliaCtx) { RemoteIP: ctx.RemoteIP().String(), } + bufHTML, bufText := &bytes.Buffer{}, &bytes.Buffer{} + if !disableHTML { if err = ctx.Providers.Templates.ExecuteEmailPasswordResetTemplate(bufHTML, values, templates.HTMLFormat); err != nil { ctx.Logger.Error(err) @@ -96,8 +96,6 @@ func ResetPasswordPOST(ctx *middlewares.AutheliaCtx) { } } - bufText := new(bytes.Buffer) - if err = ctx.Providers.Templates.ExecuteEmailPasswordResetTemplate(bufText, values, templates.PlainTextFormat); err != nil { ctx.Logger.Error(err) ctx.ReplyOK() @@ -110,7 +108,7 @@ func ResetPasswordPOST(ctx *middlewares.AutheliaCtx) { ctx.Logger.Debugf("Sending an email to user %s (%s) to inform that the password has changed.", username, addresses[0]) - if err = ctx.Providers.Notifier.Send(addresses[0], "Password changed successfully", bufText.String(), bufHTML.String()); err != nil { + if err = ctx.Providers.Notifier.Send(addresses[0], "Password changed successfully", bufText.Bytes(), bufHTML.Bytes()); err != nil { ctx.Logger.Error(err) ctx.ReplyOK() diff --git a/internal/middlewares/identity_verification.go b/internal/middlewares/identity_verification.go index 1e93f223a..6189331e4 100644 --- a/internal/middlewares/identity_verification.go +++ b/internal/middlewares/identity_verification.go @@ -62,16 +62,15 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim return } - uri, err := ctx.ExternalRootURL() - if err != nil { + var ( + uri string + ) + + if uri, err = ctx.ExternalRootURL(); err != nil { ctx.Error(err, messageOperationFailed) return } - link := fmt.Sprintf("%s%s?token=%s", uri, args.TargetEndpoint, ss) - - bufHTML := new(bytes.Buffer) - disableHTML := false if ctx.Configuration.Notifier.SMTP != nil { disableHTML = ctx.Configuration.Notifier.SMTP.DisableHTMLEmails @@ -79,12 +78,14 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim values := templates.EmailIdentityVerificationValues{ Title: args.MailTitle, - LinkURL: link, + LinkURL: fmt.Sprintf("%s%s?token=%s", uri, args.TargetEndpoint, ss), LinkText: args.MailButtonContent, DisplayName: identity.DisplayName, RemoteIP: ctx.RemoteIP().String(), } + bufHTML, bufText := &bytes.Buffer{}, &bytes.Buffer{} + if !disableHTML { if err = ctx.Providers.Templates.ExecuteEmailIdentityVerificationTemplate(bufHTML, values, templates.HTMLFormat); err != nil { ctx.Error(err, messageOperationFailed) @@ -92,8 +93,6 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim } } - bufText := new(bytes.Buffer) - if err = ctx.Providers.Templates.ExecuteEmailIdentityVerificationTemplate(bufText, values, templates.PlainTextFormat); err != nil { ctx.Error(err, messageOperationFailed) return @@ -102,7 +101,7 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim ctx.Logger.Debugf("Sending an email to user %s (%s) to confirm identity for registering a device.", identity.Username, identity.Email) - if err = ctx.Providers.Notifier.Send(mail.Address{Name: identity.DisplayName, Address: identity.Email}, args.MailTitle, bufText.String(), bufHTML.String()); err != nil { + if err = ctx.Providers.Notifier.Send(mail.Address{Name: identity.DisplayName, Address: identity.Email}, args.MailTitle, bufText.Bytes(), bufHTML.Bytes()); err != nil { ctx.Error(err, messageOperationFailed) return } diff --git a/internal/mocks/notifier.go b/internal/mocks/notifier.go index 829176b9b..208fe2eb6 100644 --- a/internal/mocks/notifier.go +++ b/internal/mocks/notifier.go @@ -35,7 +35,7 @@ func (m *MockNotifier) EXPECT() *MockNotifierMockRecorder { } // Send mocks base method. -func (m *MockNotifier) Send(arg0 mail.Address, arg1, arg2, arg3 string) error { +func (m *MockNotifier) Send(arg0 mail.Address, arg1 string, arg2, arg3 []byte) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Send", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(error) diff --git a/internal/notification/const.go b/internal/notification/const.go index f215c4d91..aee69c680 100644 --- a/internal/notification/const.go +++ b/internal/notification/const.go @@ -16,9 +16,30 @@ const ( smtpCommandAUTH = "AUTH" smtpCommandMAIL = "MAIL" smtpCommandRCPT = "RCPT" + + smtpEncodingQuotedPrintable = "quoted-printable" + smtpEncoding8bit = "8bit" + + smtpContentTypeTextPlain = "text/plain" + smtpContentTypeTextHTML = "text/html" + smtpFmtContentType = `%s; charset="UTF-8"` + smtpFmtContentDispositionInline = "inline" + + smtpExtSTARTTLS = smtpCommandSTARTTLS + smtpExt8BITMIME = "8BITMIME" +) + +const ( + headerContentType = "Content-Type" + headerContentDisposition = "Content-Disposition" + headerContentTransferEncoding = "Content-Transfer-Encoding" ) const ( fmtSMTPGenericError = "error performing %s with the SMTP server: %w" fmtSMTPDialError = "error dialing the SMTP server: %w" ) + +var ( + rfc2822DoubleNewLine = []byte("\r\n\r\n") +) diff --git a/internal/notification/file_notifier.go b/internal/notification/file_notifier.go index fe6305ae4..af2dc91e0 100644 --- a/internal/notification/file_notifier.go +++ b/internal/notification/file_notifier.go @@ -43,8 +43,8 @@ func (n *FileNotifier) StartupCheck() (err error) { } // Send send a identity verification link to a user. -func (n *FileNotifier) Send(recipient mail.Address, subject, body, _ string) error { - content := fmt.Sprintf("Date: %s\nRecipient: %s\nSubject: %s\nBody: %s", time.Now(), recipient, subject, body) +func (n *FileNotifier) Send(recipient mail.Address, subject string, bodyText, _ []byte) error { + content := fmt.Sprintf("Date: %s\nRecipient: %s\nSubject: %s\nBody: %s", time.Now(), recipient, subject, bodyText) return os.WriteFile(n.path, []byte(content), fileNotifierMode) } diff --git a/internal/notification/notifier.go b/internal/notification/notifier.go index d10509ee4..87c39a9b0 100644 --- a/internal/notification/notifier.go +++ b/internal/notification/notifier.go @@ -10,5 +10,5 @@ import ( type Notifier interface { model.StartupCheck - Send(recipient mail.Address, subject, body, htmlBody string) (err error) + Send(recipient mail.Address, subject string, bodyText, bodyHTML []byte) (err error) } diff --git a/internal/notification/smtp_notifier.go b/internal/notification/smtp_notifier.go index 8dceb73e6..de58dcba9 100644 --- a/internal/notification/smtp_notifier.go +++ b/internal/notification/smtp_notifier.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "mime/multipart" "net" "net/mail" "net/smtp" @@ -52,7 +53,7 @@ type SMTPNotifier struct { } // Send is used to email a recipient. -func (n *SMTPNotifier) Send(recipient mail.Address, title, body, htmlBody string) (err error) { +func (n *SMTPNotifier) Send(recipient mail.Address, subject string, bodyText, bodyHTML []byte) (err error) { if err = n.dial(); err != nil { return fmt.Errorf(fmtSMTPDialError, err) } @@ -65,7 +66,7 @@ func (n *SMTPNotifier) Send(recipient mail.Address, title, body, htmlBody string } // Compose and send the email body to the server. - if err = n.compose(recipient, title, body, htmlBody); err != nil { + if err = n.compose(recipient, subject, bodyText, bodyHTML); err != nil { return fmt.Errorf(fmtSMTPGenericError, smtpCommandDATA, err) } @@ -161,7 +162,7 @@ func (n *SMTPNotifier) startTLS() error { return nil } - switch ok, _ := n.client.Extension("STARTTLS"); ok { + switch ok, _ := n.client.Extension(smtpExtSTARTTLS); ok { case true: n.log.Debugf("Notifier SMTP server supports STARTTLS (disableVerifyCert: %t, ServerName: %s), attempting", n.tlsConfig.InsecureSkipVerify, n.tlsConfig.ServerName) @@ -237,7 +238,7 @@ func (n *SMTPNotifier) auth() (err error) { return nil } -func (n *SMTPNotifier) compose(recipient mail.Address, title, body, htmlBody string) (err error) { +func (n *SMTPNotifier) compose(recipient mail.Address, subject string, bodyText, bodyHTML []byte) (err error) { n.log.Debugf("Notifier SMTP client attempting to send email body to %s", recipient.String()) if !n.config.DisableRequireTLS { @@ -261,7 +262,7 @@ func (n *SMTPNotifier) compose(recipient mail.Address, title, body, htmlBody str return err } - values := templates.EmailEnvelopeValues{ + data := templates.EmailEnvelopeValues{ ProcessID: os.Getpid(), UUID: muuid.String(), Host: n.config.Host, @@ -270,21 +271,43 @@ func (n *SMTPNotifier) compose(recipient mail.Address, title, body, htmlBody str Identifier: n.config.Identifier, From: n.config.Sender.String(), To: recipient.String(), - Subject: strings.ReplaceAll(n.config.Subject, "{title}", title), + Subject: strings.ReplaceAll(n.config.Subject, "{title}", subject), Date: time.Now(), - Boundary: utils.RandomString(30, utils.AlphaNumericCharacters, true), - Body: templates.EmailEnvelopeBodyValues{ - PlainText: body, - HTML: htmlBody, - }, } - if err = n.templates.ExecuteEmailEnvelope(wc, values); err != nil { + if err = n.templates.ExecuteEmailEnvelope(wc, data); err != nil { n.log.Debugf("Notifier SMTP client error while sending email body over WriteCloser: %v", err) return err } + mwr := multipart.NewWriter(wc) + + if _, err = wc.Write([]byte(fmt.Sprintf(`Content-Type: multipart/alternative; boundary="%s"`, mwr.Boundary()))); err != nil { + return err + } + + if _, err = wc.Write(rfc2822DoubleNewLine); err != nil { + return err + } + + ext8BITMIME, _ := n.client.Extension(smtpExt8BITMIME) + + if err = multipartWrite(mwr, smtpMIMEHeaders(ext8BITMIME, smtpContentTypeTextPlain, bodyText), bodyText); err != nil { + return fmt.Errorf("failed to write text/plain part: %w", err) + } + + if len(bodyHTML) != 0 { + if err = multipartWrite(mwr, + smtpMIMEHeaders(ext8BITMIME, smtpContentTypeTextHTML, bodyText), bodyHTML); err != nil { + return fmt.Errorf("failed to write text/html part: %w", err) + } + } + + if err = mwr.Close(); err != nil { + return fmt.Errorf("failed to finalize the multipart content: %w", err) + } + if err = wc.Close(); err != nil { n.log.Debugf("Notifier SMTP client error while closing the WriteCloser: %v", err) return err diff --git a/internal/notification/smtp_util.go b/internal/notification/smtp_util.go new file mode 100644 index 000000000..4a1258e12 --- /dev/null +++ b/internal/notification/smtp_util.go @@ -0,0 +1,96 @@ +package notification + +import ( + "fmt" + "io" + "mime/multipart" + "mime/quotedprintable" + "net/textproto" + + "github.com/authelia/authelia/v4/internal/utils" +) + +func smtpMIMEHeaders(ext8BITMIME bool, contentType string, data []byte) textproto.MIMEHeader { + headers := textproto.MIMEHeader{ + headerContentType: []string{fmt.Sprintf(smtpFmtContentType, contentType)}, + headerContentDisposition: []string{smtpFmtContentDispositionInline}, + } + + characteristics := NewMIMECharacteristics(data) + + if !ext8BITMIME || characteristics.LongLines || characteristics.Characters8BIT { + headers.Set(headerContentTransferEncoding, smtpEncodingQuotedPrintable) + } else { + headers.Set(headerContentTransferEncoding, smtpEncoding8bit) + } + + return headers +} + +func multipartWrite(mwr *multipart.Writer, header textproto.MIMEHeader, data []byte) (err error) { + var ( + wc io.WriteCloser + wr io.Writer + ) + + if wr, err = mwr.CreatePart(header); err != nil { + return err + } + + switch header.Get(headerContentTransferEncoding) { + case smtpEncodingQuotedPrintable: + wc = quotedprintable.NewWriter(wr) + case smtpEncoding8bit, "": + wc = utils.NewWriteCloser(wr) + default: + return fmt.Errorf("unknown encoding: %s", header.Get(headerContentTransferEncoding)) + } + + if _, err = wc.Write(data); err != nil { + _ = wc.Close() + + return err + } + + _ = wc.Close() + + return nil +} + +// NewMIMECharacteristics detects the SMTP MIMECharacteristics for the given data bytes. +func NewMIMECharacteristics(data []byte) MIMECharacteristics { + characteristics := MIMECharacteristics{} + + cl := 0 + + n := len(data) + + for i := 0; i < n; i++ { + cl++ + + if cl > 1000 { + characteristics.LongLines = true + } + + if data[i] == 10 { + cl = 0 + + if i == 0 || data[i-1] != 13 { + characteristics.LineFeeds = true + } + } + + if data[i] >= 128 { + characteristics.Characters8BIT = true + } + } + + return characteristics +} + +// MIMECharacteristics represents specific MIME related characteristics. +type MIMECharacteristics struct { + LongLines bool + LineFeeds bool + Characters8BIT bool +} diff --git a/internal/templates/embed_test.go b/internal/templates/embed_test.go new file mode 100644 index 000000000..75080ef6e --- /dev/null +++ b/internal/templates/embed_test.go @@ -0,0 +1,16 @@ +package templates + +import ( + "path" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestShouldNotHaveDeprecatedPlaceholders(t *testing.T) { + data, err := embedFS.ReadFile(path.Join("src", TemplateCategoryNotifications, TemplateNameEmailEnvelope)) + require.NoError(t, err) + + assert.False(t, tmplEnvelopeHasDeprecatedPlaceholders(data)) +} diff --git a/internal/templates/provider.go b/internal/templates/provider.go index 7d2b2172e..500be22e9 100644 --- a/internal/templates/provider.go +++ b/internal/templates/provider.go @@ -25,23 +25,33 @@ type Provider struct { } // ExecuteEmailEnvelope writes the envelope template to the given io.Writer. -func (p Provider) ExecuteEmailEnvelope(wr io.Writer, data EmailEnvelopeValues) (err error) { +func (p *Provider) ExecuteEmailEnvelope(wr io.Writer, data EmailEnvelopeValues) (err error) { return p.templates.notification.envelope.Execute(wr, data) } // ExecuteEmailPasswordResetTemplate writes the password reset template to the given io.Writer. -func (p Provider) ExecuteEmailPasswordResetTemplate(wr io.Writer, data EmailPasswordResetValues, format Format) (err error) { +func (p *Provider) ExecuteEmailPasswordResetTemplate(wr io.Writer, data EmailPasswordResetValues, format Format) (err error) { return p.templates.notification.passwordReset.Get(format).Execute(wr, data) } // ExecuteEmailIdentityVerificationTemplate writes the identity verification template to the given io.Writer. -func (p Provider) ExecuteEmailIdentityVerificationTemplate(wr io.Writer, data EmailIdentityVerificationValues, format Format) (err error) { +func (p *Provider) ExecuteEmailIdentityVerificationTemplate(wr io.Writer, data EmailIdentityVerificationValues, format Format) (err error) { return p.templates.notification.identityVerification.Get(format).Execute(wr, data) } func (p *Provider) load() (err error) { var errs []error + if tPath, embed, data, err := readTemplate(TemplateNameEmailEnvelope, TemplateCategoryNotifications, p.config.EmailTemplatesPath); err != nil { + errs = append(errs, err) + } else { + if !embed && tmplEnvelopeHasDeprecatedPlaceholders(data) { + errs = append(errs, fmt.Errorf("the evelope template override appears to contain removed placeholders")) + } else if p.templates.notification.envelope, err = parseTemplate(TemplateNameEmailEnvelope, tPath, embed, data); err != nil { + errs = append(errs, err) + } + } + if p.templates.notification.envelope, err = loadTemplate(TemplateNameEmailEnvelope, TemplateCategoryNotifications, p.config.EmailTemplatesPath); err != nil { errs = append(errs, err) } diff --git a/internal/templates/src/notification/Envelope.tmpl b/internal/templates/src/notification/Envelope.tmpl index 007bfc821..813cbef71 100644 --- a/internal/templates/src/notification/Envelope.tmpl +++ b/internal/templates/src/notification/Envelope.tmpl @@ -3,18 +3,3 @@ To: {{ .To }} Subject: {{ .Subject }} Date: {{ .Date.Format "Mon, 2 Jan 2006 15:04:05 -0700" }} MIME-Version: 1.0 -Content-Type: multipart/alternative; boundary="{{ .Boundary }}" - ---{{ .Boundary }} -Content-Type: text/plain; charset="utf-8" -Content-Transfer-Encoding: quoted-printable -Content-Disposition: inline - -{{ .Body.PlainText }} -{{- if not (eq .Body.HTML "") }} ---{{ .Boundary }} -Content-Type: text/html; charset="utf-8" - -{{ .Body.HTML }} -{{- end }} ---{{ .Boundary }}-- diff --git a/internal/templates/types.go b/internal/templates/types.go index 2bcf8c179..daa7fc0c8 100644 --- a/internal/templates/types.go +++ b/internal/templates/types.go @@ -62,12 +62,4 @@ type EmailEnvelopeValues struct { To string Subject string Date time.Time - Boundary string - Body EmailEnvelopeBodyValues -} - -// EmailEnvelopeBodyValues are the values used for the email envelopes bodies. -type EmailEnvelopeBodyValues struct { - PlainText string - HTML string } diff --git a/internal/templates/util.go b/internal/templates/util.go index 8ff073584..f747b478a 100644 --- a/internal/templates/util.go +++ b/internal/templates/util.go @@ -1,6 +1,7 @@ package templates import ( + "bytes" "fmt" "os" "path" @@ -8,6 +9,12 @@ import ( "text/template" ) +func tmplEnvelopeHasDeprecatedPlaceholders(data []byte) bool { + return bytes.Contains(data, []byte("{{ .Boundary }}")) || + bytes.Contains(data, []byte("{{ .Body.PlainText }}")) || + bytes.Contains(data, []byte("{{ .Body.HTML }}")) +} + func templateExists(path string) (exists bool) { info, err := os.Stat(path) if err != nil { @@ -21,28 +28,51 @@ func templateExists(path string) (exists bool) { return true } -//nolint:unparam -func loadTemplate(name, category, overridePath string) (t *template.Template, err error) { +func readTemplate(name, category, overridePath string) (tPath string, embed bool, data []byte, err error) { if overridePath != "" { - tPath := filepath.Join(overridePath, name) + tPath = filepath.Join(overridePath, name) if templateExists(tPath) { - if t, err = template.ParseFiles(tPath); err != nil { - return nil, fmt.Errorf("could not parse template at path '%s': %w", tPath, err) + if data, err = os.ReadFile(tPath); err != nil { + return tPath, false, nil, fmt.Errorf("failed to read template override at path '%s': %w", tPath, err) } - return t, nil + return tPath, false, data, nil } } - data, err := embedFS.ReadFile(path.Join("src", category, name)) - if err != nil { - return nil, err + tPath = path.Join("src", category, name) + + if data, err = embedFS.ReadFile(tPath); err != nil { + return tPath, true, nil, fmt.Errorf("failed to read embedded template '%s': %w", tPath, err) } + return tPath, true, data, nil +} + +func parseTemplate(name, tPath string, embed bool, data []byte) (t *template.Template, err error) { if t, err = template.New(name).Parse(string(data)); err != nil { - panic(fmt.Errorf("failed to parse internal template: %w", err)) + if embed { + return nil, fmt.Errorf("failed to parse embedded template '%s': %w", tPath, err) + } + + return nil, fmt.Errorf("failed to parse template override at path '%s': %w", tPath, err) } return t, nil } + +//nolint:unparam +func loadTemplate(name, category, overridePath string) (t *template.Template, err error) { + var ( + embed bool + tPath string + data []byte + ) + + if tPath, embed, data, err = readTemplate(name, category, overridePath); err != nil { + return nil, err + } + + return parseTemplate(name, tPath, embed, data) +} diff --git a/internal/utils/io.go b/internal/utils/io.go new file mode 100644 index 000000000..eacaf0386 --- /dev/null +++ b/internal/utils/io.go @@ -0,0 +1,34 @@ +package utils + +import ( + "errors" + "io" +) + +// NewWriteCloser creates a new io.WriteCloser from an io.Writer. +func NewWriteCloser(wr io.Writer) io.WriteCloser { + return &WriteCloser{wr: wr} +} + +// WriteCloser is a io.Writer with an io.Closer. +type WriteCloser struct { + wr io.Writer + + closed bool +} + +// Write to the io.Writer. +func (w *WriteCloser) Write(p []byte) (n int, err error) { + if w.closed { + return -1, errors.New("already closed") + } + + return w.wr.Write(p) +} + +// Close the io.Closer. +func (w *WriteCloser) Close() error { + w.closed = true + + return nil +} diff --git a/web/src/assets/images/applestore-badge.svg b/web/src/assets/images/applestore-badge.svg index ac111e597..0fe477c56 100644 --- a/web/src/assets/images/applestore-badge.svg +++ b/web/src/assets/images/applestore-badge.svg @@ -1,129 +1,129 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/assets/images/user.svg b/web/src/assets/images/user.svg index 8a8ddd725..ddb47bfd6 100644 --- a/web/src/assets/images/user.svg +++ b/web/src/assets/images/user.svg @@ -1,21 +1,21 @@ - - - - + + + + \ No newline at end of file