Files
PrivateCaptcha/pkg/common/utils.go
2026-01-23 16:59:37 +02:00

289 lines
5.5 KiB
Go

package common
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
"unicode"
"maps"
"github.com/jpillora/backoff"
)
var (
ErrBackpressure = errors.New("backpressure error")
HeaderValueContentTypeJSON = []string{ContentTypeJSON}
errEmptyDomain = errors.New("domain name is empty")
)
func RelURL(prefix, url string) string {
url = strings.TrimPrefix(url, "/")
p := strings.Trim(prefix, "/")
if len(p) == 0 {
return "/" + url
}
return "/" + p + "/" + url
}
func MaskEmail(email string, mask rune) string {
parts := strings.Split(email, "@")
if len(parts) != 2 {
return email
}
username := parts[0]
length := len(username)
var keep int
switch length {
case 0, 1:
keep = length
case 2, 3:
keep = 1
case 4, 5:
keep = 2
case 6, 7:
keep = 3
case 8, 9:
keep = 4
default:
keep = 5
}
prefix := username[:keep]
suffix := ""
n := length - keep
if n > 5 {
n = 5
suffix = ".."
}
xxx := strings.Repeat(string(mask), n)
return prefix + xxx + suffix + "@" + parts[1]
}
func SendReponse(ctx context.Context, w http.ResponseWriter, response []byte, headers ...map[string][]string) {
wHeader := w.Header()
for _, hh := range headers {
for key, value := range hh {
wHeader[key] = value
}
}
n, err := w.Write(response)
if err != nil {
slog.ErrorContext(ctx, "Failed to send response", ErrAttr(err))
} else {
slog.Log(ctx, LevelTrace, "Sent response", "size", len(response), "sent", n)
}
}
func SendJSONResponse(ctx context.Context, w http.ResponseWriter, data interface{}, headers ...map[string][]string) {
response, err := json.Marshal(data)
if err != nil {
slog.ErrorContext(ctx, "Failed to serialise response", ErrAttr(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
wHeader := w.Header()
wHeader[HeaderContentType] = HeaderValueContentTypeJSON
for _, hh := range headers {
maps.Copy(wHeader, hh)
}
n, err := w.Write(response)
if err != nil {
slog.ErrorContext(ctx, "Failed to send response", ErrAttr(err))
} else {
slog.Log(ctx, LevelTrace, "Sent response", "serialized", len(response), "sent", n)
}
}
func ParseBoolean(value string) bool {
switch value {
case "1", "Y", "y", "yes", "Yes", "true":
return true
default:
return false
}
}
func containsAlphabetic(s string) bool {
for _, r := range s {
if unicode.IsLetter(r) {
return true
}
}
return false
}
func onlyAlphabetic(s string) bool {
for _, r := range s {
if !unicode.IsLetter(r) {
return false
}
}
return true
}
func isLowerCase(s string) bool {
for _, r := range s {
if !unicode.IsLower(r) {
return false
}
}
return true
}
func GuessFirstName(username string) string {
parts := strings.Fields(username)
for _, p := range parts {
if containsAlphabetic(p) {
if onlyAlphabetic(p) && isLowerCase(p) {
runes := []rune(p)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
return p
}
}
return username
}
func ChunkedCleanup(ctx context.Context, minInterval, maxInterval time.Duration, defaultChunkSize int, deleter func(context.Context, time.Time, int) int) {
b := &backoff.Backoff{
Min: minInterval,
Max: maxInterval,
Factor: 2,
Jitter: true,
}
slog.DebugContext(ctx, "Starting chunked clean up", "maxInterval", maxInterval.String(), "size", defaultChunkSize)
deleteChunk := defaultChunkSize
for running := true; running; {
select {
case <-ctx.Done():
running = false
case <-time.After(b.Duration()):
deleted := deleter(ctx, time.Now(), deleteChunk)
if deleted == 0 {
deleteChunk = defaultChunkSize
continue
}
slog.DebugContext(ctx, "Deleted records", "count", deleted)
// in case of any deletes, we want to go back to small interval first
b.Reset()
if deleted == deleteChunk {
// 1.5 scaling factor
deleteChunk += deleteChunk / 2
}
}
}
slog.DebugContext(ctx, "Finished cleaning up")
}
func ParseDomainName(input string) (string, error) {
if len(input) == 0 {
return "", errEmptyDomain
}
parsedURL, err := url.Parse(input)
if err != nil {
return "", err
}
domain := parsedURL.Host
if domain == "" {
domain = input
}
if slashIndex := strings.LastIndex(domain, "/"); slashIndex != -1 {
domain = domain[:slashIndex]
}
if colonIndex := strings.LastIndex(domain, ":"); colonIndex != -1 {
domain = domain[:colonIndex]
}
return domain, nil
}
func IsLocalhost(address string) bool {
return (address == "localhost") ||
(address == "127.0.0.1") ||
(address == "::1") ||
(address == "0:0:0:0:0:0:0:1")
}
func IsIPAddress(str string) bool {
_, err := netip.ParseAddr(str)
return err == nil
}
func IsSubDomainOrDomain(subDomain, domain string) bool {
if len(subDomain) == 0 || len(domain) == 0 {
return false
}
if len(subDomain) < len(domain) {
return false
}
if strings.HasSuffix(subDomain, domain) {
if lenDiff := len(subDomain) - len(domain); lenDiff > 0 {
prefix := subDomain[:lenDiff]
return strings.HasSuffix(prefix, ".") && lenDiff > 1
}
return true
}
return false
}
func EnvToBool(value string) bool {
switch value {
case "1", "Y", "y", "yes", "true", "YES", "TRUE":
return true
default:
return false
}
}
// RetriableError is a wrapper for errors that should be retried.
type RetriableError struct {
err error
}
func NewRetriableError(err error) RetriableError {
return RetriableError{err}
}
func (e RetriableError) Error() string {
return e.err.Error()
}
func (e RetriableError) Unwrap() error {
return e.err
}