package admin import ( "net/http" "regexp" "strconv" "strings" "time" "gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers" "gitea.tbdevent.eu/TBD/reforger_crawler_main/models" "github.com/gin-gonic/gin" "gorm.io/gorm" ) var sha512Re = regexp.MustCompile(`^[0-9a-f]{128}$`) // legacyAddWhitelistedHash keeps the original form-based endpoint working. func legacyAddWhitelistedHash(c *gin.Context) { hash := c.PostForm("hash") if hash == "" { c.JSON(400, gin.H{"error": "Hash is required"}) return } ret := initializers.DB.Find(&models.WhitelistedHash{}, "hash = ?", hash) if ret.RowsAffected > 0 { c.JSON(400, gin.H{"error": "Hash already whitelisted"}) return } if err := initializers.DB.Create(&models.WhitelistedHash{Hash: hash}).Error; err != nil { c.JSON(500, gin.H{"error": "Failed to add whitelisted hash"}) return } audit(c, "whitelist_add", map[string]any{"added": 1, "legacy": true}) invalidate("overview") c.JSON(200, gin.H{"message": "Whitelisted hash added successfully"}) } type whitelistRow struct { ID uint `json:"id"` Hash string `json:"hash"` Note string `json:"note"` CreatedAt time.Time `json:"createdAt"` Files int64 `json:"files"` Addons int64 `json:"addons"` SamplePath string `json:"samplePath"` } func listWhitelist(c *gin.Context) { limit := queryInt(c, "limit", 50, 1, 500) offset := queryInt(c, "offset", 0, 0, 1<<30) q := initializers.DB.Model(&models.WhitelistedHash{}) if s := strings.TrimSpace(strings.ToLower(c.Query("q"))); s != "" { like := "%" + likeEscape(s) + "%" q = q.Where(`hash LIKE ? ESCAPE '\' OR LOWER(note) LIKE ? ESCAPE '\'`, likeEscape(s)+"%", like) } var total int64 q.Session(&gorm.Session{}).Count(&total) rows := []whitelistRow{} if err := q.Select("id, hash, note, created_at").Order("id DESC").Limit(limit).Offset(offset).Scan(&rows).Error; err != nil { fail(c, http.StatusInternalServerError, err.Error()) return } if len(rows) > 0 { hashes := make([]string, len(rows)) for i, r := range rows { hashes[i] = r.Hash } var usage []struct { Hash string Files int64 Addons int64 Sample string } initializers.DB.Raw(`SELECT hash, COUNT(*) AS files, COUNT(DISTINCT addon_id) AS addons, MIN(path) AS sample FROM addon_files WHERE deleted_at IS NULL AND hash IN ? GROUP BY hash`, hashes).Scan(&usage) byHash := map[string]int{} for i, u := range usage { byHash[u.Hash] = i } for i := range rows { if j, ok := byHash[rows[i].Hash]; ok { rows[i].Files, rows[i].Addons, rows[i].SamplePath = usage[j].Files, usage[j].Addons, usage[j].Sample } } } c.JSON(http.StatusOK, gin.H{"items": rows, "total": total, "limit": limit, "offset": offset}) } type skipped struct { Hash string `json:"hash"` Reason string `json:"reason"` } func addWhitelist(c *gin.Context) { var body struct { Hashes []string `json:"hashes"` Text string `json:"text"` Note string `json:"note"` } if err := c.ShouldBindJSON(&body); err != nil { fail(c, http.StatusBadRequest, "invalid JSON") return } candidates := append([]string{}, body.Hashes...) candidates = append(candidates, strings.FieldsFunc(body.Text, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\r' || r == '\t' })...) if len(candidates) == 0 { fail(c, http.StatusBadRequest, "no hashes given") return } if len(candidates) > 5000 { fail(c, http.StatusBadRequest, "at most 5000 hashes per request") return } note := body.Note if len(note) > 256 { note = note[:256] } var skips []skipped seen := map[string]bool{} valid := []string{} for _, raw := range candidates { h := strings.ToLower(strings.TrimSpace(raw)) switch { case h == "": continue case !sha512Re.MatchString(h): skips = append(skips, skipped{raw, "not a SHA-512 hex hash"}) case seen[h]: skips = append(skips, skipped{h, "duplicate in request"}) default: seen[h] = true valid = append(valid, h) } } added := 0 err := initializers.DB.Transaction(func(tx *gorm.DB) error { existing := map[string]bool{} for start := 0; start < len(valid); start += 500 { end := min(start+500, len(valid)) var found []string if err := tx.Model(&models.WhitelistedHash{}).Where("hash IN ?", valid[start:end]).Pluck("hash", &found).Error; err != nil { return err } for _, h := range found { existing[h] = true } } rows := []models.WhitelistedHash{} for _, h := range valid { if existing[h] { skips = append(skips, skipped{h, "already whitelisted"}) continue } rows = append(rows, models.WhitelistedHash{Hash: h, Note: note}) } if len(rows) == 0 { return nil } added = len(rows) return tx.CreateInBatches(&rows, 200).Error }) if err != nil { fail(c, http.StatusInternalServerError, err.Error()) return } if added > 0 { audit(c, "whitelist_add", map[string]any{"added": added, "note": note}) invalidate("overview") } if skips == nil { skips = []skipped{} } c.JSON(http.StatusOK, gin.H{"added": added, "skipped": skips}) } func deleteWhitelist(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { fail(c, http.StatusBadRequest, "invalid id") return } var row models.WhitelistedHash if err := initializers.DB.Unscoped().First(&row, id).Error; err != nil { fail(c, http.StatusNotFound, "not found") return } // remove every row of this hash, the table has no unique constraint res := initializers.DB.Unscoped().Where("hash = ?", row.Hash).Delete(&models.WhitelistedHash{}) if res.Error != nil { fail(c, http.StatusInternalServerError, res.Error.Error()) return } audit(c, "whitelist_remove", map[string]any{"hash": row.Hash}) invalidate("overview") c.JSON(http.StatusOK, gin.H{"deleted": res.RowsAffected}) } // hashUsage lists the addons that contain a file with the given hash. func hashUsage(c *gin.Context) { hash := strings.ToLower(c.Param("hash")) var total int64 initializers.DB.Model(&models.AddonFile{}).Where("hash = ?", hash).Count(&total) items := []struct { AddonID string `json:"addonId"` Name string `json:"name"` Path string `json:"path"` Version string `json:"version"` }{} err := initializers.DB.Raw(`SELECT f.addon_id, COALESCE(a.name, '') AS name, f.path, f.version FROM addon_files f LEFT JOIN addons a ON a.id = f.addon_id WHERE f.hash = ? AND f.deleted_at IS NULL ORDER BY a.subscriber_count DESC LIMIT 200`, hash).Scan(&items).Error if err != nil { fail(c, http.StatusInternalServerError, err.Error()) return } var wl int64 initializers.DB.Model(&models.WhitelistedHash{}).Where("hash = ?", hash).Count(&wl) c.JSON(http.StatusOK, gin.H{"hash": hash, "whitelisted": wl > 0, "total": total, "items": items}) }