2024-07-10 19:49:07 +03:00
|
|
|
package backendpb
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"fmt"
|
2024-12-05 14:19:25 +03:00
|
|
|
"log/slog"
|
2024-07-10 19:49:07 +03:00
|
|
|
"net/netip"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/AdguardTeam/AdGuardDNS/internal/access"
|
|
|
|
"github.com/AdguardTeam/AdGuardDNS/internal/agd"
|
|
|
|
"github.com/AdguardTeam/AdGuardDNS/internal/agdtime"
|
|
|
|
"github.com/AdguardTeam/AdGuardDNS/internal/dnsmsg"
|
|
|
|
"github.com/AdguardTeam/AdGuardDNS/internal/errcoll"
|
2024-12-05 14:19:25 +03:00
|
|
|
"github.com/AdguardTeam/AdGuardDNS/internal/filter"
|
2024-07-10 19:49:07 +03:00
|
|
|
"github.com/AdguardTeam/AdGuardDNS/internal/geoip"
|
|
|
|
"github.com/AdguardTeam/golibs/errors"
|
|
|
|
"github.com/AdguardTeam/golibs/netutil"
|
2024-10-14 17:44:24 +03:00
|
|
|
"github.com/c2h5oh/datasize"
|
2024-07-10 19:49:07 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
// toInternal converts the protobuf-encoded data into a profile structure and
|
|
|
|
// its device structures.
|
2024-10-14 17:44:24 +03:00
|
|
|
//
|
2024-12-05 14:19:25 +03:00
|
|
|
// TODO(a.garipov): Refactor into methods of [*ProfileStorage].
|
2024-07-10 19:49:07 +03:00
|
|
|
func (x *DNSProfile) toInternal(
|
|
|
|
ctx context.Context,
|
|
|
|
updTime time.Time,
|
|
|
|
bindSet netutil.SubnetSet,
|
|
|
|
errColl errcoll.Interface,
|
2024-12-05 14:19:25 +03:00
|
|
|
logger *slog.Logger,
|
|
|
|
mtrc ProfileDBMetrics,
|
2024-10-14 17:44:24 +03:00
|
|
|
respSzEst datasize.ByteSize,
|
2024-07-10 19:49:07 +03:00
|
|
|
) (profile *agd.Profile, devices []*agd.Device, err error) {
|
|
|
|
if x == nil {
|
|
|
|
return nil, nil, fmt.Errorf("profile is nil")
|
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
parental, err := x.Parental.toInternal(ctx, errColl, logger)
|
2024-07-10 19:49:07 +03:00
|
|
|
if err != nil {
|
|
|
|
return nil, nil, fmt.Errorf("parental: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
m, err := blockingModeToInternal(x.BlockingMode)
|
|
|
|
if err != nil {
|
|
|
|
return nil, nil, fmt.Errorf("blocking mode: %w", err)
|
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
devices, deviceIds := devicesToInternal(ctx, x.Devices, bindSet, errColl, logger, mtrc)
|
2024-07-10 19:49:07 +03:00
|
|
|
|
|
|
|
profID, err := agd.NewProfileID(x.DnsId)
|
|
|
|
if err != nil {
|
|
|
|
return nil, nil, fmt.Errorf("id: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
var fltRespTTL time.Duration
|
|
|
|
if respTTL := x.FilteredResponseTtl; respTTL != nil {
|
|
|
|
fltRespTTL = respTTL.AsDuration()
|
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
customRules := rulesToInternal(ctx, x.CustomRules, errColl, logger)
|
|
|
|
custom := &filter.ConfigCustom{
|
|
|
|
ID: string(x.DnsId),
|
|
|
|
UpdateTime: updTime,
|
|
|
|
Rules: customRules,
|
|
|
|
// TODO(a.garipov): Consider adding an explicit flag to the protocol.
|
|
|
|
Enabled: len(customRules) > 0,
|
|
|
|
}
|
|
|
|
|
2024-07-10 19:49:07 +03:00
|
|
|
return &agd.Profile{
|
2024-12-05 14:19:25 +03:00
|
|
|
FilterConfig: &filter.ConfigClient{
|
|
|
|
Custom: custom,
|
|
|
|
Parental: parental,
|
|
|
|
RuleList: x.RuleLists.toInternal(ctx, errColl, logger),
|
|
|
|
SafeBrowsing: x.SafeBrowsing.toInternal(),
|
|
|
|
},
|
|
|
|
Access: x.Access.toInternal(ctx, errColl, logger),
|
2024-07-10 19:49:07 +03:00
|
|
|
BlockingMode: m,
|
2024-12-05 14:19:25 +03:00
|
|
|
Ratelimiter: x.RateLimit.toInternal(ctx, errColl, logger, respSzEst),
|
2024-07-10 19:49:07 +03:00
|
|
|
ID: profID,
|
|
|
|
DeviceIDs: deviceIds,
|
|
|
|
FilteredResponseTTL: fltRespTTL,
|
2024-12-05 14:19:25 +03:00
|
|
|
AutoDevicesEnabled: x.AutoDevicesEnabled,
|
|
|
|
BlockChromePrefetch: x.BlockChromePrefetch,
|
2024-07-10 19:49:07 +03:00
|
|
|
BlockFirefoxCanary: x.BlockFirefoxCanary,
|
2024-12-05 14:19:25 +03:00
|
|
|
BlockPrivateRelay: x.BlockPrivateRelay,
|
|
|
|
Deleted: x.Deleted,
|
|
|
|
FilteringEnabled: x.FilteringEnabled,
|
2024-07-10 19:49:07 +03:00
|
|
|
IPLogEnabled: x.IpLogEnabled,
|
2024-12-05 14:19:25 +03:00
|
|
|
QueryLogEnabled: x.QueryLogEnabled,
|
2024-07-10 19:49:07 +03:00
|
|
|
}, devices, nil
|
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
// toInternal converts a protobuf parental-protection settings structure to an
|
|
|
|
// internal one. If x is nil, toInternal returns a disabled configuration.
|
2024-07-10 19:49:07 +03:00
|
|
|
func (x *ParentalSettings) toInternal(
|
|
|
|
ctx context.Context,
|
|
|
|
errColl errcoll.Interface,
|
2024-12-05 14:19:25 +03:00
|
|
|
logger *slog.Logger,
|
|
|
|
) (c *filter.ConfigParental, err error) {
|
|
|
|
c = &filter.ConfigParental{}
|
2024-07-10 19:49:07 +03:00
|
|
|
if x == nil {
|
2024-12-05 14:19:25 +03:00
|
|
|
return c, nil
|
2024-07-10 19:49:07 +03:00
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
c.AdultBlockingEnabled = x.BlockAdult
|
|
|
|
c.BlockedServices = blockedSvcsToInternal(ctx, errColl, logger, x.BlockedServices)
|
|
|
|
c.Enabled = x.Enabled
|
|
|
|
c.SafeSearchGeneralEnabled = x.GeneralSafeSearch
|
|
|
|
c.SafeSearchYouTubeEnabled = x.YoutubeSafeSearch
|
|
|
|
|
|
|
|
c.PauseSchedule, err = x.Schedule.toInternal()
|
2024-07-10 19:49:07 +03:00
|
|
|
if err != nil {
|
2024-12-05 14:19:25 +03:00
|
|
|
return nil, fmt.Errorf("pause schedule: %w", err)
|
2024-07-10 19:49:07 +03:00
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
return c, nil
|
2024-07-10 19:49:07 +03:00
|
|
|
}
|
|
|
|
|
2024-10-14 17:44:24 +03:00
|
|
|
// toInternal converts protobuf rate-limiting settings to an internal structure.
|
|
|
|
// If x is nil, toInternal returns [agd.GlobalRatelimiter].
|
|
|
|
func (x *RateLimitSettings) toInternal(
|
|
|
|
ctx context.Context,
|
|
|
|
errColl errcoll.Interface,
|
2024-12-05 14:19:25 +03:00
|
|
|
logger *slog.Logger,
|
2024-10-14 17:44:24 +03:00
|
|
|
respSzEst datasize.ByteSize,
|
|
|
|
) (r agd.Ratelimiter) {
|
|
|
|
if x == nil || !x.Enabled {
|
|
|
|
return agd.GlobalRatelimiter{}
|
|
|
|
}
|
|
|
|
|
|
|
|
return agd.NewDefaultRatelimiter(&agd.RatelimitConfig{
|
2024-12-05 14:19:25 +03:00
|
|
|
ClientSubnets: cidrRangeToInternal(ctx, errColl, logger, x.ClientCidr),
|
2024-10-14 17:44:24 +03:00
|
|
|
RPS: x.Rps,
|
|
|
|
Enabled: x.Enabled,
|
|
|
|
}, respSzEst)
|
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
// toInternal converts protobuf safe-browsing settings to an internal
|
|
|
|
// safe-browsing configuration. If x is nil, toInternal returns a disabled
|
|
|
|
// configuration.
|
|
|
|
func (x *SafeBrowsingSettings) toInternal() (c *filter.ConfigSafeBrowsing) {
|
|
|
|
c = &filter.ConfigSafeBrowsing{}
|
2024-07-10 19:49:07 +03:00
|
|
|
if x == nil {
|
2024-12-05 14:19:25 +03:00
|
|
|
return c
|
2024-07-10 19:49:07 +03:00
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
c.Enabled = x.Enabled
|
|
|
|
c.DangerousDomainsEnabled = x.BlockDangerousDomains
|
|
|
|
c.NewlyRegisteredDomainsEnabled = x.BlockNrd
|
|
|
|
|
|
|
|
return c
|
2024-07-10 19:49:07 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// toInternal converts protobuf access settings to an internal structure. If x
|
|
|
|
// is nil, toInternal returns [access.EmptyProfile].
|
|
|
|
func (x *AccessSettings) toInternal(
|
|
|
|
ctx context.Context,
|
|
|
|
errColl errcoll.Interface,
|
2024-12-05 14:19:25 +03:00
|
|
|
logger *slog.Logger,
|
2024-07-10 19:49:07 +03:00
|
|
|
) (a access.Profile) {
|
|
|
|
if x == nil || !x.Enabled {
|
|
|
|
return access.EmptyProfile{}
|
|
|
|
}
|
|
|
|
|
|
|
|
return access.NewDefaultProfile(&access.ProfileConfig{
|
2024-12-05 14:19:25 +03:00
|
|
|
AllowedNets: cidrRangeToInternal(ctx, errColl, logger, x.AllowlistCidr),
|
|
|
|
BlockedNets: cidrRangeToInternal(ctx, errColl, logger, x.BlocklistCidr),
|
2024-07-10 19:49:07 +03:00
|
|
|
AllowedASN: asnToInternal(x.AllowlistAsn),
|
|
|
|
BlockedASN: asnToInternal(x.BlocklistAsn),
|
|
|
|
BlocklistDomainRules: x.BlocklistDomainRules,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
// cidrRangeToInternal is a helper that converts a slice of CidrRange to the
|
|
|
|
// slice of [netip.Prefix].
|
|
|
|
func cidrRangeToInternal(
|
|
|
|
ctx context.Context,
|
|
|
|
errColl errcoll.Interface,
|
2024-12-05 14:19:25 +03:00
|
|
|
logger *slog.Logger,
|
2024-07-10 19:49:07 +03:00
|
|
|
cidrs []*CidrRange,
|
|
|
|
) (out []netip.Prefix) {
|
|
|
|
for i, c := range cidrs {
|
|
|
|
addr, ok := netip.AddrFromSlice(c.Address)
|
|
|
|
if !ok {
|
2024-12-05 14:19:25 +03:00
|
|
|
err := fmt.Errorf("bad cidr at index %d: %v", i, c.Address)
|
|
|
|
errcoll.Collect(ctx, errColl, logger, "converting cidrs", err)
|
2024-07-10 19:49:07 +03:00
|
|
|
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
out = append(out, netip.PrefixFrom(addr, int(c.Prefix)))
|
|
|
|
}
|
|
|
|
|
|
|
|
return out
|
|
|
|
}
|
|
|
|
|
|
|
|
// asnToInternal is a helper that converts a slice of ASNs to the slice of
|
|
|
|
// [geoip.ASN].
|
|
|
|
func asnToInternal(asns []uint32) (out []geoip.ASN) {
|
|
|
|
for _, asn := range asns {
|
|
|
|
out = append(out, geoip.ASN(asn))
|
|
|
|
}
|
|
|
|
|
|
|
|
return out
|
|
|
|
}
|
|
|
|
|
|
|
|
// blockedSvcsToInternal is a helper that converts the blocked service IDs from
|
2024-12-05 14:19:25 +03:00
|
|
|
// the backend response to AdGuard DNS blocked-service IDs.
|
2024-07-10 19:49:07 +03:00
|
|
|
func blockedSvcsToInternal(
|
|
|
|
ctx context.Context,
|
|
|
|
errColl errcoll.Interface,
|
2024-12-05 14:19:25 +03:00
|
|
|
logger *slog.Logger,
|
2024-07-10 19:49:07 +03:00
|
|
|
respSvcs []string,
|
2024-12-05 14:19:25 +03:00
|
|
|
) (ids []filter.BlockedServiceID) {
|
2024-07-10 19:49:07 +03:00
|
|
|
l := len(respSvcs)
|
|
|
|
if l == 0 {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
ids = make([]filter.BlockedServiceID, 0, l)
|
|
|
|
for i, idStr := range respSvcs {
|
|
|
|
id, err := filter.NewBlockedServiceID(idStr)
|
2024-07-10 19:49:07 +03:00
|
|
|
if err != nil {
|
2024-12-05 14:19:25 +03:00
|
|
|
err = fmt.Errorf("at index %d: %w", i, err)
|
|
|
|
errcoll.Collect(ctx, errColl, logger, "converting blocked services", err)
|
2024-07-10 19:49:07 +03:00
|
|
|
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
ids = append(ids, id)
|
2024-07-10 19:49:07 +03:00
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
return ids
|
2024-07-10 19:49:07 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// toInternal converts a protobuf protection-schedule structure to an internal
|
|
|
|
// one. If x is nil, toInternal returns nil.
|
2024-12-05 14:19:25 +03:00
|
|
|
func (x *ScheduleSettings) toInternal() (c *filter.ConfigSchedule, err error) {
|
2024-07-10 19:49:07 +03:00
|
|
|
if x == nil {
|
|
|
|
return nil, nil
|
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
c = &filter.ConfigSchedule{
|
|
|
|
Week: &filter.WeeklySchedule{},
|
|
|
|
}
|
2024-07-10 19:49:07 +03:00
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
c.TimeZone, err = agdtime.LoadLocation(x.Tmz)
|
2024-07-10 19:49:07 +03:00
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("loading timezone: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
w := x.WeeklyRange
|
|
|
|
days := []*DayRange{w.Sun, w.Mon, w.Tue, w.Wed, w.Thu, w.Fri, w.Sat}
|
|
|
|
for i, d := range days {
|
|
|
|
if d == nil {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
ivl := &filter.DayInterval{
|
2024-07-10 19:49:07 +03:00
|
|
|
Start: uint16(d.Start.AsDuration().Minutes()),
|
2024-12-05 14:19:25 +03:00
|
|
|
End: uint16(d.End.AsDuration().Minutes() + 1),
|
2024-07-10 19:49:07 +03:00
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
err = ivl.Validate()
|
2024-07-10 19:49:07 +03:00
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("weekday %s: %w", time.Weekday(i), err)
|
|
|
|
}
|
2024-12-05 14:19:25 +03:00
|
|
|
|
|
|
|
c.Week[i] = ivl
|
2024-07-10 19:49:07 +03:00
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
return c, nil
|
2024-07-10 19:49:07 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// toInternal converts a protobuf custom blocking-mode to an internal one.
|
|
|
|
// Assumes that at least one IP address is specified in the result blocking-mode
|
|
|
|
// object.
|
|
|
|
func (pbm *BlockingModeCustomIP) toInternal() (m dnsmsg.BlockingMode, err error) {
|
|
|
|
custom := &dnsmsg.BlockingModeCustomIP{}
|
|
|
|
|
|
|
|
// TODO(a.garipov): Only one IPv4 address is supported on protobuf side.
|
|
|
|
var ipv4Addr netip.Addr
|
|
|
|
err = ipv4Addr.UnmarshalBinary(pbm.Ipv4)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("bad custom ipv4: %w", err)
|
|
|
|
} else if ipv4Addr.IsValid() {
|
|
|
|
custom.IPv4 = []netip.Addr{ipv4Addr}
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO(a.garipov): Only one IPv6 address is supported on protobuf side.
|
|
|
|
var ipv6Addr netip.Addr
|
|
|
|
err = ipv6Addr.UnmarshalBinary(pbm.Ipv6)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("bad custom ipv6: %w", err)
|
|
|
|
} else if ipv6Addr.IsValid() {
|
|
|
|
custom.IPv6 = []netip.Addr{ipv6Addr}
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(custom.IPv4)+len(custom.IPv6) == 0 {
|
|
|
|
return nil, errors.Error("no valid custom ips found")
|
|
|
|
}
|
|
|
|
|
|
|
|
return custom, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// blockingModeToInternal converts a protobuf blocking-mode sum-type to an
|
|
|
|
// internal one. If pbm is nil, blockingModeToInternal returns a null-IP
|
|
|
|
// blocking mode.
|
|
|
|
func blockingModeToInternal(pbm isDNSProfile_BlockingMode) (m dnsmsg.BlockingMode, err error) {
|
|
|
|
switch pbm := pbm.(type) {
|
|
|
|
case nil:
|
|
|
|
return &dnsmsg.BlockingModeNullIP{}, nil
|
|
|
|
case *DNSProfile_BlockingModeCustomIp:
|
|
|
|
return pbm.BlockingModeCustomIp.toInternal()
|
|
|
|
case *DNSProfile_BlockingModeNxdomain:
|
|
|
|
return &dnsmsg.BlockingModeNXDOMAIN{}, nil
|
|
|
|
case *DNSProfile_BlockingModeNullIp:
|
|
|
|
return &dnsmsg.BlockingModeNullIP{}, nil
|
|
|
|
case *DNSProfile_BlockingModeRefused:
|
|
|
|
return &dnsmsg.BlockingModeREFUSED{}, nil
|
|
|
|
default:
|
|
|
|
// Consider unhandled type-switch cases programmer errors.
|
|
|
|
return nil, fmt.Errorf("bad pb blocking mode %T(%[1]v)", pbm)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// rulesToInternal is a helper that converts the filter rules from the backend
|
|
|
|
// response to AdGuard DNS filtering rules.
|
|
|
|
func rulesToInternal(
|
|
|
|
ctx context.Context,
|
|
|
|
respRules []string,
|
|
|
|
errColl errcoll.Interface,
|
2024-12-05 14:19:25 +03:00
|
|
|
logger *slog.Logger,
|
|
|
|
) (rules []filter.RuleText) {
|
2024-07-10 19:49:07 +03:00
|
|
|
l := len(respRules)
|
|
|
|
if l == 0 {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
rules = make([]filter.RuleText, 0, l)
|
2024-07-10 19:49:07 +03:00
|
|
|
for i, r := range respRules {
|
2024-12-05 14:19:25 +03:00
|
|
|
text, err := filter.NewRuleText(r)
|
2024-07-10 19:49:07 +03:00
|
|
|
if err != nil {
|
2024-12-05 14:19:25 +03:00
|
|
|
err = fmt.Errorf("at index %d: %w", i, err)
|
|
|
|
errcoll.Collect(ctx, errColl, logger, "converting rules", err)
|
2024-07-10 19:49:07 +03:00
|
|
|
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
rules = append(rules, text)
|
|
|
|
}
|
|
|
|
|
|
|
|
return rules
|
|
|
|
}
|
|
|
|
|
|
|
|
// toInternal is a helper that converts the filter lists from the backend
|
2024-12-05 14:19:25 +03:00
|
|
|
// response to AdGuard DNS rule-list configuration. If x is nil, toInternal
|
|
|
|
// returns a disabled configuration.
|
2024-07-10 19:49:07 +03:00
|
|
|
func (x *RuleListsSettings) toInternal(
|
|
|
|
ctx context.Context,
|
|
|
|
errColl errcoll.Interface,
|
2024-12-05 14:19:25 +03:00
|
|
|
logger *slog.Logger,
|
|
|
|
) (c *filter.ConfigRuleList) {
|
|
|
|
c = &filter.ConfigRuleList{}
|
2024-07-10 19:49:07 +03:00
|
|
|
if x == nil {
|
2024-12-05 14:19:25 +03:00
|
|
|
return c
|
2024-07-10 19:49:07 +03:00
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
c.Enabled = x.Enabled
|
|
|
|
c.IDs = make([]filter.ID, 0, len(x.Ids))
|
2024-07-10 19:49:07 +03:00
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
for i, idStr := range x.Ids {
|
|
|
|
id, err := filter.NewID(idStr)
|
2024-07-10 19:49:07 +03:00
|
|
|
if err != nil {
|
2024-12-05 14:19:25 +03:00
|
|
|
err = fmt.Errorf("at index %d: %w", i, err)
|
|
|
|
errcoll.Collect(ctx, errColl, logger, "converting filter id", err)
|
2024-07-10 19:49:07 +03:00
|
|
|
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
c.IDs = append(c.IDs, id)
|
2024-07-10 19:49:07 +03:00
|
|
|
}
|
|
|
|
|
2024-12-05 14:19:25 +03:00
|
|
|
return c
|
2024-07-10 19:49:07 +03:00
|
|
|
}
|