refac: removed 7 direct and 12 indirect go modules for custom code instead
This commit is contained in:
1 parent
cfcf108488
commit
c68e9a7858
73 files changed
+2455
-428
No files matched your search
@@ -0,0 +1,142 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
dotenvExportPrefix = "export "
|
||||
dotenvCommentMarker = "#"
|
||||
dotenvInlineComment = " #"
|
||||
dotenvByteOrderMark = "\xef\xbb\xbf"
|
||||
dotenvMaxLineBytes = 1 << 20
|
||||
dotenvAssignment = "="
|
||||
dotenvDoubleQuote = '"'
|
||||
dotenvSingleQuote = '\''
|
||||
dotenvEscapeBackslash = '\\'
|
||||
)
|
||||
|
||||
var dotenvDoubleQuoteEscapes = map[byte]byte{
|
||||
'n': '\n',
|
||||
'r': '\r',
|
||||
't': '\t',
|
||||
'"': '"',
|
||||
'\\': '\\',
|
||||
}
|
||||
|
||||
func LoadDotenv(path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
values, err := ParseDotenv(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
for name, value := range values {
|
||||
if _, isSet := os.LookupEnv(name); !isSet {
|
||||
if err := os.Setenv(name, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseDotenv(reader io.Reader) (map[string]string, error) {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Buffer(make([]byte, 0, bufio.MaxScanTokenSize), dotenvMaxLineBytes)
|
||||
|
||||
values := map[string]string{}
|
||||
for lineNumber := 1; scanner.Scan(); lineNumber++ {
|
||||
line := strings.TrimSpace(strings.TrimPrefix(scanner.Text(), dotenvByteOrderMark))
|
||||
if line == "" || strings.HasPrefix(line, dotenvCommentMarker) {
|
||||
continue
|
||||
}
|
||||
name, value, err := parseDotenvAssignment(line)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("line %d: %w", lineNumber, err)
|
||||
}
|
||||
values[name] = value
|
||||
}
|
||||
return values, scanner.Err()
|
||||
}
|
||||
|
||||
func parseDotenvAssignment(line string) (string, string, error) {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, dotenvExportPrefix))
|
||||
name, raw, found := strings.Cut(line, dotenvAssignment)
|
||||
name = strings.TrimSpace(name)
|
||||
if !found || !isDotenvName(name) {
|
||||
return "", "", fmt.Errorf("expected NAME=value")
|
||||
}
|
||||
value, err := parseDotenvValue(strings.TrimSpace(raw))
|
||||
return name, value, err
|
||||
}
|
||||
|
||||
func isDotenvName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range name {
|
||||
isLetter := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z'
|
||||
isDigit := r >= '0' && r <= '9'
|
||||
if !isLetter && !isDigit && r != '_' && r != '.' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseDotenvValue(raw string) (string, error) {
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
switch raw[0] {
|
||||
case dotenvDoubleQuote:
|
||||
return parseDoubleQuoted(raw)
|
||||
case dotenvSingleQuote:
|
||||
return parseSingleQuoted(raw)
|
||||
}
|
||||
if comment := strings.Index(raw, dotenvInlineComment); comment >= 0 {
|
||||
raw = raw[:comment]
|
||||
}
|
||||
return strings.TrimSpace(raw), nil
|
||||
}
|
||||
|
||||
func parseSingleQuoted(raw string) (string, error) {
|
||||
end := strings.IndexByte(raw[1:], dotenvSingleQuote)
|
||||
if end < 0 {
|
||||
return "", fmt.Errorf("unterminated single quote")
|
||||
}
|
||||
return raw[1 : 1+end], nil
|
||||
}
|
||||
|
||||
func parseDoubleQuoted(raw string) (string, error) {
|
||||
var value strings.Builder
|
||||
for i := 1; i < len(raw); i++ {
|
||||
switch raw[i] {
|
||||
case dotenvDoubleQuote:
|
||||
return value.String(), nil
|
||||
case dotenvEscapeBackslash:
|
||||
i++
|
||||
if i >= len(raw) {
|
||||
return "", fmt.Errorf("unterminated double quote")
|
||||
}
|
||||
if unescaped, known := dotenvDoubleQuoteEscapes[raw[i]]; known {
|
||||
value.WriteByte(unescaped)
|
||||
} else {
|
||||
value.WriteByte(dotenvEscapeBackslash)
|
||||
value.WriteByte(raw[i])
|
||||
}
|
||||
default:
|
||||
value.WriteByte(raw[i])
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("unterminated double quote")
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"errors"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type loginAuth struct {
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
if !server.TLS && !isLoopback(server.Name) {
|
||||
return "", nil, errors.New("refusing to send credentials over an unencrypted connection")
|
||||
}
|
||||
return "LOGIN", nil, nil
|
||||
}
|
||||
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if !more {
|
||||
return nil, nil
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(string(fromServer))) {
|
||||
case "username:":
|
||||
return []byte(a.username), nil
|
||||
case "password:":
|
||||
return []byte(a.password), nil
|
||||
}
|
||||
return nil, errors.New("unexpected server challenge")
|
||||
}
|
||||
|
||||
func isLoopback(host string) bool {
|
||||
return host == "localhost" || host == "127.0.0.1" || host == "::1"
|
||||
}
|
||||
|
||||
func selectAuth(mechanisms string, settings config.SmtpSettings) smtp.Auth {
|
||||
advertised := strings.Fields(strings.ToUpper(mechanisms))
|
||||
for _, mechanism := range advertised {
|
||||
if mechanism == "PLAIN" {
|
||||
return smtp.PlainAuth("", settings.Login, settings.Password, settings.Host)
|
||||
}
|
||||
}
|
||||
return &loginAuth{username: settings.Login, password: settings.Password}
|
||||
}
|
||||
@@ -4,22 +4,47 @@ import (
|
||||
domain "RenewCMS/internal/domain/mail"
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"embed"
|
||||
"html/template"
|
||||
"log"
|
||||
|
||||
"gopkg.in/gomail.v2"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MailRepository struct {
|
||||
smtp config.SmtpSettings
|
||||
transport transport
|
||||
sender string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type Option func(*options)
|
||||
|
||||
type options struct {
|
||||
tlsConfig *tls.Config
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func WithTLSConfig(tlsConfig *tls.Config) Option {
|
||||
return func(o *options) { o.tlsConfig = tlsConfig }
|
||||
}
|
||||
|
||||
func WithClock(now func() time.Time) Option {
|
||||
return func(o *options) { o.now = now }
|
||||
}
|
||||
|
||||
//go:embed templates/*
|
||||
var mailTemplateFiles embed.FS
|
||||
|
||||
func NewMailRepository(smtp config.SmtpSettings) *MailRepository {
|
||||
return &MailRepository{smtp: smtp}
|
||||
func NewMailRepository(smtp config.SmtpSettings, opts ...Option) *MailRepository {
|
||||
configured := options{now: time.Now}
|
||||
for _, opt := range opts {
|
||||
opt(&configured)
|
||||
}
|
||||
return &MailRepository{
|
||||
transport: newTransport(smtp, configured.tlsConfig),
|
||||
sender: smtp.Sender,
|
||||
now: configured.now,
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSubject = "RenewCMS | Action required"
|
||||
@@ -60,28 +85,23 @@ func (m MailRepository) Render(templateName string, data any) (string, string, e
|
||||
}
|
||||
|
||||
func (m MailRepository) Send(receiverAddress string, templateName string, data any) error {
|
||||
login := m.smtp.Login
|
||||
sender := m.smtp.Sender
|
||||
password := m.smtp.Password
|
||||
smtpHost := m.smtp.Host
|
||||
smtpPort := m.smtp.Port
|
||||
|
||||
d := gomail.NewDialer(smtpHost, smtpPort, login, password)
|
||||
|
||||
subject, body, err := m.Render(templateName, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := gomail.NewMessage()
|
||||
msg.SetHeaders(map[string][]string{
|
||||
"From": {"RenewCMS <" + sender + ">"},
|
||||
"To": {receiverAddress},
|
||||
"Subject": {subject},
|
||||
})
|
||||
msg.SetBody("text/html", body)
|
||||
msg, err := newMessage(m.sender, receiverAddress, subject, body, m.now())
|
||||
if err != nil {
|
||||
log.Println("error when building email:", err)
|
||||
return err
|
||||
}
|
||||
payload, err := msg.bytes()
|
||||
if err != nil {
|
||||
log.Println("error when encoding email:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := d.DialAndSend(msg); err != nil {
|
||||
if err := m.transport.deliver(msg.from.Address, msg.to.Address, payload); err != nil {
|
||||
log.Println("error when sending email:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
senderDisplayName = "RenewCMS"
|
||||
messageIDBytes = 16
|
||||
lineBreak = "\r\n"
|
||||
)
|
||||
|
||||
type message struct {
|
||||
from mail.Address
|
||||
to mail.Address
|
||||
subject string
|
||||
body string
|
||||
sentAt time.Time
|
||||
}
|
||||
|
||||
func newMessage(sender string, receiver string, subject string, body string, sentAt time.Time) (message, error) {
|
||||
from, err := mail.ParseAddress(sender)
|
||||
if err != nil {
|
||||
return message{}, fmt.Errorf("invalid sender address: %w", err)
|
||||
}
|
||||
to, err := mail.ParseAddress(receiver)
|
||||
if err != nil {
|
||||
return message{}, fmt.Errorf("invalid receiver address: %w", err)
|
||||
}
|
||||
if strings.ContainsAny(subject, "\r\n") {
|
||||
return message{}, errors.New("subject contains a line break")
|
||||
}
|
||||
return message{
|
||||
from: mail.Address{Name: senderDisplayName, Address: from.Address},
|
||||
to: *to,
|
||||
subject: subject,
|
||||
body: body,
|
||||
sentAt: sentAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m message) messageID() (string, error) {
|
||||
_, domain, _ := strings.Cut(m.from.Address, "@")
|
||||
random := make([]byte, messageIDBytes)
|
||||
if _, err := rand.Read(random); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "<" + hex.EncodeToString(random) + "@" + domain + ">", nil
|
||||
}
|
||||
|
||||
func (m message) bytes() ([]byte, error) {
|
||||
id, err := m.messageID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
headers := []struct{ name, value string }{
|
||||
{"From", m.from.String()},
|
||||
{"To", m.to.String()},
|
||||
{"Subject", mime.QEncoding.Encode("utf-8", m.subject)},
|
||||
{"Date", m.sentAt.Format(time.RFC1123Z)},
|
||||
{"Message-ID", id},
|
||||
{"MIME-Version", "1.0"},
|
||||
{"Content-Type", "text/html; charset=UTF-8"},
|
||||
{"Content-Transfer-Encoding", "quoted-printable"},
|
||||
}
|
||||
for _, h := range headers {
|
||||
out.WriteString(h.name + ": " + h.value + lineBreak)
|
||||
}
|
||||
out.WriteString(lineBreak)
|
||||
|
||||
encoder := quotedprintable.NewWriter(&out)
|
||||
if _, err := encoder.Write([]byte(m.body)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
implicitTLSPort = 465
|
||||
dialTimeout = 10 * time.Second
|
||||
sessionTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
type transport struct {
|
||||
settings config.SmtpSettings
|
||||
tlsConfig *tls.Config
|
||||
}
|
||||
|
||||
func newTransport(settings config.SmtpSettings, tlsConfig *tls.Config) transport {
|
||||
if tlsConfig == nil {
|
||||
tlsConfig = &tls.Config{}
|
||||
}
|
||||
tlsConfig = tlsConfig.Clone()
|
||||
if tlsConfig.ServerName == "" {
|
||||
tlsConfig.ServerName = settings.Host
|
||||
}
|
||||
if tlsConfig.MinVersion == 0 {
|
||||
tlsConfig.MinVersion = tls.VersionTLS12
|
||||
}
|
||||
return transport{settings: settings, tlsConfig: tlsConfig}
|
||||
}
|
||||
|
||||
func (t transport) deliver(sender string, receiver string, payload []byte) error {
|
||||
client, err := t.connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
if err := t.negotiate(client); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Mail(sender); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(receiver); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeData(client, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func (t transport) connect() (*smtp.Client, error) {
|
||||
address := net.JoinHostPort(t.settings.Host, strconv.Itoa(t.settings.Port))
|
||||
dialer := &net.Dialer{Timeout: dialTimeout}
|
||||
|
||||
connection, err := dialer.Dial("tcp", address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := connection.SetDeadline(time.Now().Add(sessionTimeout)); err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
if t.settings.Port == implicitTLSPort {
|
||||
connection = tls.Client(connection, t.tlsConfig)
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(connection, t.settings.Host)
|
||||
if err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (t transport) negotiate(client *smtp.Client) error {
|
||||
if t.settings.Port != implicitTLSPort {
|
||||
if supported, _ := client.Extension("STARTTLS"); supported {
|
||||
if err := client.StartTLS(t.tlsConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return t.authenticate(client)
|
||||
}
|
||||
|
||||
func (t transport) authenticate(client *smtp.Client) error {
|
||||
if t.settings.Login == "" {
|
||||
return nil
|
||||
}
|
||||
advertised, mechanisms := client.Extension("AUTH")
|
||||
if !advertised {
|
||||
return errors.New("smtp server does not support authentication")
|
||||
}
|
||||
return client.Auth(selectAuth(mechanisms, t.settings))
|
||||
}
|
||||
|
||||
func writeData(client *smtp.Client, payload []byte) error {
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.Write(payload); err != nil {
|
||||
_ = writer.Close()
|
||||
return err
|
||||
}
|
||||
return writer.Close()
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -54,7 +52,7 @@ type ServeVariantUseCase struct {
|
||||
maxSourceBytes int64
|
||||
maxSourcePixels int64
|
||||
renderSlots chan struct{}
|
||||
flights singleflight.Group
|
||||
flights flightGroup
|
||||
tracker image.VariantUsageTracker
|
||||
}
|
||||
|
||||
@@ -214,11 +212,11 @@ func (u *ServeVariantUseCase) lookupCached(variantName string, storedName string
|
||||
func (u *ServeVariantUseCase) generated(ctx context.Context, variant image.Variant, storedName string, fingerprint string, previous image.VariantFile) (image.VariantFile, error) {
|
||||
key := strings.Join([]string{variant.Name, fingerprint, storedName}, flightKeySep)
|
||||
for {
|
||||
file, err, _ := u.flights.Do(key, func() (any, error) {
|
||||
file, err := u.flights.do(key, func() (image.VariantFile, error) {
|
||||
return u.generate(ctx, variant, storedName, fingerprint, previous)
|
||||
})
|
||||
if err == nil {
|
||||
return file.(image.VariantFile), nil
|
||||
return file, nil
|
||||
}
|
||||
if ctx.Err() != nil || !errors.Is(err, context.Canceled) {
|
||||
return image.VariantFile{}, err
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var errFlightAbandoned = errors.New("shared call ended without a result")
|
||||
|
||||
type flightCall struct {
|
||||
done chan struct{}
|
||||
value image.VariantFile
|
||||
err error
|
||||
}
|
||||
|
||||
type flightGroup struct {
|
||||
mu sync.Mutex
|
||||
calls map[string]*flightCall
|
||||
}
|
||||
|
||||
func (g *flightGroup) do(key string, work func() (image.VariantFile, error)) (image.VariantFile, error) {
|
||||
g.mu.Lock()
|
||||
if existing, running := g.calls[key]; running {
|
||||
g.mu.Unlock()
|
||||
<-existing.done
|
||||
return existing.value, existing.err
|
||||
}
|
||||
if g.calls == nil {
|
||||
g.calls = map[string]*flightCall{}
|
||||
}
|
||||
call := &flightCall{done: make(chan struct{}), err: errFlightAbandoned}
|
||||
g.calls[key] = call
|
||||
g.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
g.mu.Lock()
|
||||
delete(g.calls, key)
|
||||
g.mu.Unlock()
|
||||
close(call.done)
|
||||
}()
|
||||
|
||||
call.value, call.err = work()
|
||||
return call.value, call.err
|
||||
}
|
||||
Reference in new issue
Block a user