package models import ( "strings" "rocketgit.ru/rsmon/worker/app/models/concerns" ) // RknDomain is one row of the locally cached ru-blocked domains list. The // `Domain` field carries a lowercased domain string and is the unique key. type RknDomain struct { concerns.Model Domain string `gorm:"size:255;uniqueIndex;not null" json:"domain"` } // TableName pins the underlying table name so GORM migrations and raw SQL // (used by IsRknDomainBlocked and the rkn updater) agree. func (RknDomain) TableName() string { return "rkn_domains" } // ReplaceRknDomains deletes every existing row and inserts the given domains // in batches. Caller-supplied domains are lower-cased and de-duplicated, and // blank entries are dropped. The whole operation runs in a single // transaction so readers see either the old set or the new set — there is no // in-between state where the table is half-flushed. // // Designed to be called once per parser-updater tick (default every 6h); the // uniqueIndex on Domain guarantees idempotent re-runs even when the caller // forgets to filter. func ReplaceRknDomains(domains []string) error { if domains == nil { domains = []string{} } deduped := make([]string, 0, len(domains)) seen := make(map[string]struct{}, len(domains)) for _, d := range domains { d = strings.ToLower(strings.TrimSpace(d)) if d == "" { continue } if _, ok := seen[d]; ok { continue } seen[d] = struct{}{} deduped = append(deduped, d) } tx := DB().Begin() if tx.Error != nil { return tx.Error } // Step 1 — wipe the existing cache. Using a scoped Where("1 = 1") Delete // instead of TRUNCATE so the advisory lock taken by Migrate() does not // become a bottleneck and so any in-flight foreign-key checks against // rkn_domains remain satisfied (the table has no FKs today, but this // matches the convention used by Drop()). if err := tx.Where("1 = 1").Delete(&RknDomain{}).Error; err != nil { _ = tx.Rollback().Error return err } // Step 2 — bulk-insert in chunks of 1000 rows. CreateInBatches runs N // multi-row INSERT statements, which for the typical ~30k ru-blocked // entries is ~3ms per batch — significantly cheaper than per-row // Create() in tight loops (the previous AddRknDomain implementation). const batchSize = 1000 for start := 0; start < len(deduped); start += batchSize { end := start + batchSize if end > len(deduped) { end = len(deduped) } rows := make([]RknDomain, 0, end-start) for _, d := range deduped[start:end] { rows = append(rows, RknDomain{Domain: d}) } if err := tx.CreateInBatches(rows, batchSize).Error; err != nil { _ = tx.Rollback().Error return err } } return tx.Commit().Error } // IsRknDomainBlocked returns true iff `domain` (or its root label, or any // parent suffix already recorded as `*.parent.tld`) is present in the // rkn_domains table. // // Matching rules — see checks/crkn/rkn_init.go for the original logic we // consolidate here: // 1. Exact match against the stored domain string. // 2. Root-domain match (last two labels of the input) — covers the case // where the user passed a subdomain but the upstream only lists the // apex. // 3. Suffix match (`stored LIKE '%' || input || ?`) — covers the case // where the user passed the apex (or a higher-level label) but the // upstream lists a child subdomain. // // All three checks are combined into a single SQL statement via OR so the // table is scanned at most once and the SQL planner can pick a single // index access path. func IsRknDomainBlocked(domain string) (bool, error) { domain = strings.ToLower(strings.TrimSpace(domain)) if domain == "" { return false, nil } rootDomain := rootDomainOf(domain) // Build the suffix patterns once. Note: every ".X" entry in the table // (i.e. a domain that begins with a dot) matches any subdomain whose // suffix is domain. suffixPattern := "%." + domain var count int64 err := DB().Raw( "SELECT COUNT(*) FROM rkn_domains WHERE domain = ? OR domain = ? OR domain LIKE ?", domain, rootDomain, suffixPattern, ).Scan(&count).Error if err != nil { return false, err } return count > 0, nil } // rootDomainOf returns the last two labels of `domain` (e.g. "a.b.c" → "b.c"). // Returns `domain` unchanged when it has fewer than three labels, because a // one- or two-label input IS already the apex/root domain. func rootDomainOf(domain string) string { i := strings.LastIndex(domain, ".") if i < 0 { return domain } j := strings.LastIndex(domain[:i], ".") if j < 0 { return domain } return domain[j+1:] }