Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules

This commit is contained in:
2026-10-02 12:11:49 +03:00
parent 8dcbc6bc52
commit 7b8e28b3bf
151 changed files with 68625 additions and 44 deletions
+48
View File
@@ -0,0 +1,48 @@
using System;
using System.IO;
using Microsoft.EntityFrameworkCore;
using Telemetry.Models;
namespace Telemetry;
public class AppDbContext : DbContext
{
public DbSet<LogModelSql> Logs { get; set; } = null!;
public DbSet<UserInfoModelSql> Users { get; set; } = null!;
public AppDbContext() : base() { }
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
var dbPath = ModInit.DbPath;
var dbDir = Path.GetDirectoryName(dbPath);
if (dbDir != null && !Directory.Exists(dbDir)) Directory.CreateDirectory(dbDir);
optionsBuilder.UseSqlite($"Data Source={dbPath};Cache=Shared");
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<LogModelSql>()
.HasIndex(l => l.Time);
modelBuilder.Entity<LogModelSql>()
.HasIndex(l => new { l.Uid, l.Time });
modelBuilder.Entity<LogModelSql>()
.HasIndex(l => new { l.Balancer, l.Time });
modelBuilder.Entity<LogModelSql>()
.HasIndex(l => l.UnfoId);
modelBuilder.Entity<UserInfoModelSql>()
.HasKey(u => u.Id);
modelBuilder.Entity<UserInfoModelSql>()
.HasIndex(u => u.Ip);
}
}
@@ -0,0 +1,539 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Shared;
using Shared.Attributes;
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Telemetry.Models;
using System.Collections.Generic;
namespace Telemetry.Controllers;
[Authorization(redirectUri: "/adminpanel/auth")]
[Route("/telemetry")]
public class TelemetryController : BaseController
{
private readonly IDbContextFactory<AppDbContext> _contextFactory;
public TelemetryController(IDbContextFactory<AppDbContext> contextFactory)
{
_contextFactory = contextFactory;
}
private class MovieRawDto
{
public string? Title { get; set; }
public string? Tmdb { get; set; }
public string? Kp { get; set; }
public string? Imdb { get; set; }
public bool IsTv { get; set; }
public int Count { get; set; }
}
private static (DateTime start, DateTime end) GetPeriod(int days)
{
var now = DateTime.UtcNow.Date;
var periodStart = days == 0 ? now : (days == -1 ? now.AddDays(-1) : now.AddDays(-days));
var periodEnd = days == -1 ? now : DateTime.UtcNow;
return (periodStart, periodEnd);
}
private static List<object> MapMovies(List<MovieRawDto> raw)
{
return raw.Select(m => new
{
title = m.Title,
id = m.Tmdb ?? m.Kp ?? m.Imdb ?? m.Title,
tmdb = m.Tmdb,
kp = m.Kp,
imdb = m.Imdb,
count = m.Count,
source = m.Tmdb != null ? "tmdb" : m.Kp != null ? "kinopoisk" : m.Imdb != null ? "imdb" : "unknown",
isTv = m.IsTv
}).Cast<object>().ToList();
}
private IQueryable<LogModelSql> ApplySearchFilter(AppDbContext db, IQueryable<LogModelSql> query, string? search)
{
if (string.IsNullOrEmpty(search)) return query;
var rawSearch = search;
var encodedSearch = Uri.EscapeDataString(search);
var encodedSearchPlus = encodedSearch.Replace("%20", "+");
return query.Where(l =>
l.Uid.Contains(rawSearch) ||
l.Uri.Contains(rawSearch) ||
l.Uri.Contains(encodedSearch) ||
l.Uri.Contains(encodedSearchPlus) ||
(l.Balancer != null && l.Balancer.Contains(rawSearch)) ||
db.Users.Any(u => u.Id == l.UnfoId && (u.Ip.Contains(rawSearch) || u.UserAgent.Contains(rawSearch)))
);
}
[HttpGet]
[Route("")]
public ActionResult Index()
{
var path = Path.Combine(ModInit.modpath, "index.html");
if (!System.IO.File.Exists(path)) return Content("UI missing", "text/plain");
var html = System.IO.File.ReadAllText(path, Encoding.UTF8);
return Content(html, "text/html; charset=utf-8");
}
[HttpGet("api/stats")]
public async Task<IActionResult> Stats(string? search, int days = 30)
{
await using var db = await _contextFactory.CreateDbContextAsync();
var query = db.Logs.AsNoTracking();
query = ApplySearchFilter(db, query, search);
var (periodStart, periodEnd) = GetPeriod(days);
var now = DateTime.UtcNow.Date;
int total;
int today;
if (days == 0)
{
today = await query.Where(l => l.Time >= now.Date && l.Time < periodEnd).CountAsync();
total = today;
}
else if (days == -1)
{
total = await query.Where(l => l.Time >= periodStart && l.Time < periodEnd).CountAsync();
today = 0;
}
else
{
total = await query.Where(l => l.Time >= periodStart && l.Time < periodEnd).CountAsync();
today = await query.Where(l => l.Time >= now.Date && l.Time < periodEnd).CountAsync();
}
var uids = await query.Where(l => l.Time >= periodStart && l.Time < periodEnd && l.Uid != "anonymous" && !string.IsNullOrEmpty(l.Uid)).Select(l => l.Uid).Distinct().CountAsync();
var validLogs = query.Where(l => l.Time >= periodStart && l.Time < periodEnd && l.UnfoId != null);
var ips = await validLogs.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.Ip).Where(ip => ip != null && ip != "").Distinct().CountAsync();
var uas = await validLogs.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent).Where(ua => ua != null && ua != "").Distinct().CountAsync();
return new JsonResult(new { success = true, data = new { today, total, uids, ips, uas } });
}
[HttpGet("api/logs")]
public async Task<IActionResult> Logs(string? search, int days = 30, int skip = 0, int take = 100)
{
take = Math.Min(take, 1000);
await using var db = await _contextFactory.CreateDbContextAsync();
var query = db.Logs.AsNoTracking();
var (periodStart, periodEnd) = GetPeriod(days);
query = query.Where(l => l.Time >= periodStart && l.Time < periodEnd);
query = ApplySearchFilter(db, query, search);
var logs = await query.OrderByDescending(l => l.Time).Skip(skip).Take(take)
.GroupJoin(db.Users, l => l.UnfoId, u => u.Id, (l, users) => new { Log = l, Users = users })
.SelectMany(x => x.Users.DefaultIfEmpty(), (x, u) => new
{
id = x.Log.Id,
time = x.Log.Time,
uri = x.Log.Uri,
uid = x.Log.Uid,
ip = u != null ? u.Ip : "unknown",
userAgent = u != null ? u.UserAgent : "unknown",
balancer = x.Log.Balancer,
statusCode = x.Log.StatusCode,
duration = x.Log.DurationMs
})
.ToListAsync();
return new JsonResult(new { success = true, data = logs });
}
[HttpGet("api/chart")]
public async Task<IActionResult> Chart(string? search, int days = 30)
{
var (periodStart, periodEnd) = GetPeriod(days);
await using var db = await _contextFactory.CreateDbContextAsync();
var query = db.Logs.AsNoTracking();
query = ApplySearchFilter(db, query, search);
var data = await query
.Where(l => l.Time >= periodStart && l.Time < periodEnd)
.GroupBy(l => l.Time.Date)
.Select(g => new { date = g.Key, count = g.Count() })
.OrderBy(x => x.date)
.ToListAsync();
return new JsonResult(new { success = true, data = data });
}
[HttpGet("api/tops")]
public async Task<IActionResult> GetTops(string? search, [FromQuery] int days = 7)
{
await using var db = await _contextFactory.CreateDbContextAsync();
var (periodStart, periodEnd) = GetPeriod(days);
var query = db.Logs.AsNoTracking();
query = ApplySearchFilter(db, query, search);
var baseQuery = query.Where(l => l.Time >= periodStart && l.Time < periodEnd);
var topBalancers = await baseQuery
.Where(l => l.Balancer != null && l.Balancer != "" && !l.Balancer.StartsWith("18+ "))
.GroupBy(l => l.Balancer)
.Select(g => new { balancer = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.Take(10)
.ToListAsync();
var topAdult = await baseQuery
.Where(l => l.Balancer != null && l.Balancer.StartsWith("18+ "))
.GroupBy(l => l.Balancer)
.Select(g => new { balancer = g.Key!.Substring(4), count = g.Count() })
.OrderByDescending(x => x.count)
.Take(10)
.ToListAsync();
var topUsers = await baseQuery
.Where(l => l.Uid != "anonymous" && l.Uid != null && l.Uid != "")
.GroupBy(l => l.Uid)
.Select(g => new { uid = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.Take(10)
.ToListAsync();
var topMoviesRaw = await baseQuery
.Where(l => l.MovieTitle != null && l.MovieTitle != "")
.GroupBy(l => l.MovieTitle)
.Select(g => new MovieRawDto
{
Title = g.Key,
Tmdb = g.Max(l => l.TmdbId),
Kp = g.Max(l => l.KpId),
Imdb = g.Max(l => l.ImdbId),
IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1,
Count = g.Count()
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToListAsync();
var topMovies = MapMovies(topMoviesRaw);
var userAgents = await baseQuery
.Where(l => l.UnfoId != null)
.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent)
.GroupBy(ua => ua)
.Select(g => new { ua = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.Take(1000)
.ToListAsync();
var devices = userAgents
.Select(x => new { name = ParseDevice(x.ua), count = x.count })
.GroupBy(x => x.name)
.Select(g => new { name = g.Key, count = g.Sum(x => x.count) })
.OrderByDescending(x => x.count)
.ToList();
var hours = await baseQuery
.GroupBy(l => l.Time.Hour)
.Select(g => new { hour = g.Key, count = g.Count() })
.OrderBy(x => x.hour)
.ToListAsync();
var seriesCount = await baseQuery.Where(l => l.IsTv).CountAsync();
var moviesCount = await baseQuery.Where(l => l.MovieTitle != null && l.MovieTitle != "" && !l.IsTv).CountAsync();
var animeCount = await baseQuery.Where(l => l.Uri.Contains("anime=1") || l.Uri.Contains("anime=true")).CountAsync();
var contentTypes = new[] {
new { name = "Фильмы", count = moviesCount },
new { name = "Сериалы", count = seriesCount },
new { name = "Аниме", count = animeCount }
}.Where(x => x.count > 0).ToList();
return new JsonResult(new { success = true, data = new { balancers = topBalancers, adult = topAdult, users = topUsers, movies = topMovies, devices, hours, contentTypes } });
}
private string ParseDevice(string? ua)
{
if (ua == null) return "Другие";
ua = ua.ToLower();
if (ua.Contains("tizen") || ua.Contains("samsung")) return "Tizen";
if (ua.Contains("web0s") || ua.Contains("webos")) return "WebOS";
if (ua.Contains("android")) return "Android";
if (ua.Contains("iphone") || ua.Contains("ipad") || ua.Contains("mac os")) return "Apple";
if (ua.Contains("windows")) return "Windows";
if (ua.Contains("linux")) return "Linux";
return "Другие";
}
[HttpGet("api/user")]
public async Task<IActionResult> UserInfo(string uid, int days = 30)
{
if (string.IsNullOrEmpty(uid)) return BadRequest();
await using var db = await _contextFactory.CreateDbContextAsync();
var (periodStart, periodEnd) = GetPeriod(days);
var baseQuery = db.Logs.AsNoTracking().Where(l => l.Uid == uid && l.Time >= periodStart && l.Time < periodEnd);
var count = await baseQuery.CountAsync();
if (count == 0) return new JsonResult(new { success = false });
var totalRequests = count;
var firstSeen = await baseQuery.MinAsync(l => l.Time);
var lastSeen = await baseQuery.MaxAsync(l => l.Time);
var ips = await baseQuery.Where(l => l.UnfoId != null).Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.Ip).Where(ip => ip != null && ip != "").Distinct().ToListAsync();
var userAgents = await baseQuery.Where(l => l.UnfoId != null).Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent).Where(ua => ua != null && ua != "").Distinct().ToListAsync();
var balancers = await baseQuery.Where(l => l.Balancer != null && l.Balancer != "")
.GroupBy(l => l.Balancer)
.Select(g => new { name = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.ToListAsync();
var isHourly = days <= 1;
var timeline = await (isHourly
? baseQuery.GroupBy(l => new DateTime(l.Time.Year, l.Time.Month, l.Time.Day, l.Time.Hour, 0, 0)).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync()
: baseQuery.GroupBy(l => l.Time.Date).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync());
var moviesRaw = await baseQuery
.Where(l => l.MovieTitle != null && l.MovieTitle != "")
.GroupBy(l => l.MovieTitle)
.Select(g => new MovieRawDto
{
Title = g.Key,
Tmdb = g.Max(l => l.TmdbId),
Kp = g.Max(l => l.KpId),
Imdb = g.Max(l => l.ImdbId),
IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1,
Count = g.Count()
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToListAsync();
var movies = MapMovies(moviesRaw);
return new JsonResult(new
{
success = true,
data = new
{
totalRequests,
firstSeen,
lastSeen,
ips,
userAgents,
balancers,
timeline,
movies
}
});
}
[HttpGet("api/ip")]
public async Task<IActionResult> IpInfo(string ip, int days = 30)
{
if (string.IsNullOrEmpty(ip)) return BadRequest();
await using var db = await _contextFactory.CreateDbContextAsync();
var (periodStart, periodEnd) = GetPeriod(days);
var baseQuery = db.Logs.AsNoTracking().Where(l => l.Time >= periodStart && l.Time < periodEnd && l.UnfoId != null && db.Users.Any(u => u.Id == l.UnfoId && u.Ip == ip));
var count = await baseQuery.CountAsync();
if (count == 0) return new JsonResult(new { success = false });
var totalRequests = count;
var firstSeen = await baseQuery.MinAsync(l => l.Time);
var lastSeen = await baseQuery.MaxAsync(l => l.Time);
var uids = await baseQuery.Where(l => l.Uid != "anonymous" && l.Uid != null && l.Uid != "").Select(l => l.Uid).Distinct().ToListAsync();
var userAgents = await baseQuery.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent).Where(ua => ua != null && ua != "").Distinct().ToListAsync();
var balancers = await baseQuery.Where(l => l.Balancer != null && l.Balancer != "")
.GroupBy(l => l.Balancer)
.Select(g => new { name = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.ToListAsync();
var isHourly = days <= 1;
var timeline = await (isHourly
? baseQuery.GroupBy(l => new DateTime(l.Time.Year, l.Time.Month, l.Time.Day, l.Time.Hour, 0, 0)).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync()
: baseQuery.GroupBy(l => l.Time.Date).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync());
var moviesRaw = await baseQuery
.Where(l => l.MovieTitle != null && l.MovieTitle != "")
.GroupBy(l => l.MovieTitle)
.Select(g => new MovieRawDto
{
Title = g.Key,
Tmdb = g.Max(l => l.TmdbId),
Kp = g.Max(l => l.KpId),
Imdb = g.Max(l => l.ImdbId),
IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1,
Count = g.Count()
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToListAsync();
var movies = MapMovies(moviesRaw);
return new JsonResult(new
{
success = true,
data = new
{
totalRequests,
firstSeen,
lastSeen,
uids,
userAgents,
balancers,
timeline,
movies
}
});
}
[HttpGet("api/settings")]
public IActionResult GetSettings()
{
return new JsonResult(new
{
success = true,
data = new
{
prefixes = string.Join("\n", RequestListener.SkipPrefixes),
extensions = string.Join("\n", RequestListener.SkipExtensions),
regexes = string.Join("\n", RequestListener.RegexFilters),
ips = string.Join("\n", RequestListener.BlacklistIps),
rateLimit = RequestListener.RateLimitMs
}
});
}
[HttpPost("api/settings")]
public IActionResult UpdateSettings([FromForm] string? prefixes, [FromForm] string? extensions, [FromForm] string? regexes, [FromForm] string? ips, [FromForm] int? rateLimit)
{
if (prefixes != null) RequestListener.SkipPrefixes = prefixes.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray();
if (extensions != null) RequestListener.SkipExtensions = extensions.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray();
if (regexes != null)
{
var patterns = regexes.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray();
var invalid = new List<string>();
foreach (var p in patterns)
{
try { System.Text.RegularExpressions.Regex.Match("", p, System.Text.RegularExpressions.RegexOptions.None, TimeSpan.FromMilliseconds(50)); }
catch { invalid.Add(p); }
}
if (invalid.Count > 0)
return new JsonResult(new { success = false, error = $"Невалидные regex: {string.Join(", ", invalid)}" });
RequestListener.RegexFilters = patterns;
}
if (ips != null) RequestListener.BlacklistIps = ips.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray();
if (rateLimit.HasValue) RequestListener.RateLimitMs = rateLimit.Value;
RequestListener.SaveSettings();
return new JsonResult(new { success = true });
}
[HttpGet("api/balancer")]
public async Task<IActionResult> BalancerInfo(string balancer, int days = 30)
{
if (string.IsNullOrEmpty(balancer)) return BadRequest();
await using var db = await _contextFactory.CreateDbContextAsync();
var (periodStart, periodEnd) = GetPeriod(days);
var now = DateTime.UtcNow.Date;
var baseQuery = db.Logs.AsNoTracking().Where(l => l.Balancer == balancer && l.Time >= periodStart && l.Time < periodEnd);
var total = await baseQuery.CountAsync();
var today = await baseQuery.Where(l => l.Time >= now.Date).CountAsync();
var topUids = await baseQuery.Where(l => l.Uid != "anonymous" && l.Uid != null && l.Uid != "")
.GroupBy(l => l.Uid)
.Select(g => new { uid = g.Key, count = g.Count() })
.OrderByDescending(x => x.count).Take(10).ToListAsync();
var topIps = await baseQuery.Where(l => l.UnfoId != null)
.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.Ip)
.Where(ip => ip != null && ip != "")
.GroupBy(ip => ip)
.Select(g => new { ip = g.Key, count = g.Count() })
.OrderByDescending(x => x.count).Take(10).ToListAsync();
var isHourly = days <= 1;
var timeline = await (isHourly
? baseQuery.GroupBy(l => new DateTime(l.Time.Year, l.Time.Month, l.Time.Day, l.Time.Hour, 0, 0)).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync()
: baseQuery.GroupBy(l => l.Time.Date).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync());
var moviesRaw = await baseQuery
.Where(l => l.MovieTitle != null && l.MovieTitle != "")
.GroupBy(l => l.MovieTitle)
.Select(g => new MovieRawDto
{
Title = g.Key,
Tmdb = g.Max(l => l.TmdbId),
Kp = g.Max(l => l.KpId),
Imdb = g.Max(l => l.ImdbId),
IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1,
Count = g.Count()
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToListAsync();
var movies = MapMovies(moviesRaw);
return new JsonResult(new { success = true, data = new { total, today, uids = topUids, ips = topIps, timeline, movies } });
}
[HttpPost("api/logs/delete")]
public async Task<IActionResult> DeleteLogs([FromForm] string? search, [FromForm] int days = 30)
{
await using var db = await _contextFactory.CreateDbContextAsync();
var now = DateTime.UtcNow.Date;
var periodStart = days == 0 ? now : (days == -1 ? now.AddDays(-1) : now.AddDays(-days));
var periodEnd = days == -1 ? now : DateTime.UtcNow;
var query = db.Logs.Where(l => l.Time >= periodStart && l.Time < periodEnd);
query = ApplySearchFilter(db, query, search);
var deleted = await query.ExecuteDeleteAsync();
return new JsonResult(new { success = true, deleted });
}
[HttpGet("api/export")]
public async Task<IActionResult> ExportData()
{
await using var db = await _contextFactory.CreateDbContextAsync();
var result = await db.Logs.AsNoTracking()
.OrderByDescending(l => l.Id)
.Take(50000)
.GroupJoin(db.Users, l => l.UnfoId, u => u.Id, (l, users) => new { Log = l, Users = users })
.SelectMany(x => x.Users.DefaultIfEmpty(), (x, u) => new
{
time = x.Log.Time,
uri = x.Log.Uri,
uid = x.Log.Uid,
ip = u != null ? u.Ip : "",
userAgent = u != null ? u.UserAgent : "",
balancer = x.Log.Balancer,
statusCode = x.Log.StatusCode
})
.ToListAsync();
return new JsonResult(result);
}
}
+148
View File
@@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Shared;
using Shared.Models;
using Shared.Models.Base;
using Shared.Models.Events;
using Shared.Models.Module;
using Shared.Models.Module.Interfaces;
using Telemetry.Models;
namespace Telemetry;
public class ModInit : IModuleLoaded, IModuleConfigure
{
public static string modpath { get; private set; } = string.Empty;
public static readonly string DbPath = Path.Combine(AppContext.BaseDirectory, "database", "Telemetry", "stats.db");
private static Timer? _updateDbTimer;
private static Timer? _cleanupTimer;
private static int _updatingDb = 0;
public void Configure(ConfigureModel app)
{
app.services.AddDbContextFactory<AppDbContext>(options =>
{
options.UseSqlite($"Data Source={DbPath};Cache=Shared");
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});
}
public void Loaded(InitspaceModel initspace)
{
modpath = initspace.path;
var dbDir = Path.GetDirectoryName(DbPath);
if (dbDir != null) Directory.CreateDirectory(dbDir);
using (var sqlDb = new AppDbContext())
{
sqlDb.Database.EnsureCreated();
try
{
sqlDb.Database.ExecuteSqlRaw("PRAGMA journal_mode = WAL;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA synchronous = NORMAL;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA cache_size = -64000;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA temp_store = MEMORY;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA mmap_size = 33554432;");
}
catch { }
}
_updateDbTimer?.Dispose();
_cleanupTimer?.Dispose();
_updateDbTimer = new Timer(UpdateDb, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
_cleanupTimer = new Timer(CleanupDb, null, TimeSpan.FromMinutes(1), TimeSpan.FromHours(12));
RequestListener.LoadSettings();
EventListener.MiddlewareAsync -= RequestListener.InvokeAsync;
EventListener.MiddlewareAsync += RequestListener.InvokeAsync;
Console.WriteLine("[Telemetry] Module loaded.");
}
private static void UpdateDb(object? state)
{
if (Interlocked.Exchange(ref _updatingDb, 1) == 1) return;
try
{
using var sqlDb = new AppDbContext();
sqlDb.ChangeTracker.AutoDetectChangesEnabled = false;
var batch = new List<(LogModelSql log, UserInfoModelSql user)>();
while (batch.Count < 2000 && RequestListener.Queue.TryDequeue(out var item))
{
RequestListener.DequeueItem();
batch.Add(item);
}
if (batch.Count == 0) return;
var userIds = batch.Select(b => b.user.Id).Distinct().ToList();
var existingUsers = sqlDb.Users.Where(u => userIds.Contains(u.Id)).Select(u => u.Id).ToHashSet();
foreach (var item in batch.OrderBy(i => i.log.Time))
{
if (!existingUsers.Contains(item.user.Id))
{
sqlDb.Users.Add(item.user);
existingUsers.Add(item.user.Id);
}
sqlDb.Logs.Add(item.log);
}
sqlDb.SaveChanges();
}
catch (Exception ex)
{
Console.WriteLine($"[Telemetry] DB Update Error: {ex.Message}");
}
finally { Interlocked.Exchange(ref _updatingDb, 0); }
}
private static void CleanupDb(object? state)
{
try
{
// Settings should be configurable, hardcoded for now: 90 days or max 500MB DB
int logDay = 90;
var cutoff = DateTime.UtcNow.AddDays(-logDay);
using var sqlDb = new AppDbContext();
int batchSize = 5000;
// Delete old records
while (true)
{
var ids = sqlDb.Logs.Where(l => l.Time < cutoff).Select(l => l.Id).Take(batchSize).ToList();
if (ids.Count == 0) break;
sqlDb.Logs.Where(l => ids.Contains(l.Id)).ExecuteDelete();
if (ids.Count < batchSize) break;
}
// Delete orphaned users who have no logs left
sqlDb.Users.Where(u => !sqlDb.Logs.Any(l => l.UnfoId == u.Id)).ExecuteDelete();
// Run VACUUM to reclaim deleted disk space
try { sqlDb.Database.ExecuteSqlRaw("VACUUM;"); } catch { }
}
catch (Exception ex)
{
Console.WriteLine($"[Telemetry] DB Cleanup Error: {ex.Message}");
}
}
public void Dispose()
{
EventListener.MiddlewareAsync -= RequestListener.InvokeAsync;
_updateDbTimer?.Dispose();
_cleanupTimer?.Dispose();
Console.WriteLine("[Telemetry] Module disposed.");
}
}
+56
View File
@@ -0,0 +1,56 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Telemetry.Models;
public class LogModelSql
{
[Key]
public long Id { get; set; }
public DateTime Time { get; set; }
[MaxLength(2048)]
public string Uri { get; set; } = string.Empty;
[MaxLength(256)]
public string Uid { get; set; } = string.Empty;
[MaxLength(64)]
public string? UnfoId { get; set; }
public int DurationMs { get; set; }
[MaxLength(128)]
public string? Balancer { get; set; }
public int StatusCode { get; set; }
[MaxLength(256)]
public string? MovieTitle { get; set; }
[MaxLength(32)]
public string? TmdbId { get; set; }
[MaxLength(32)]
public string? KpId { get; set; }
[MaxLength(32)]
public string? ImdbId { get; set; }
public bool IsTv { get; set; }
}
public class UserInfoModelSql
{
[Key]
[MaxLength(64)]
public string Id { get; set; } = string.Empty;
[MaxLength(64)]
public string Ip { get; set; } = string.Empty;
[MaxLength(1024)]
public string UserAgent { get; set; } = string.Empty;
}
+273
View File
@@ -0,0 +1,273 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using Shared;
using Shared.Models;
using Shared.Models.Base;
using Shared.Models.Events;
using Shared.Models.Module;
using Shared.Models.Module.Interfaces;
using Telemetry.Models;
namespace Telemetry;
public static class RequestListener
{
public static readonly ConcurrentQueue<(LogModelSql log, UserInfoModelSql user)> Queue = new();
private static int _queueSize = 0;
private const int MaxQueueSize = 50000;
private static readonly MemoryCache _rateLimitCache = new(new MemoryCacheOptions { SizeLimit = 100_000 });
private static readonly MemoryCache _balancerDedupCache = new(new MemoryCacheOptions { SizeLimit = 50_000 });
private static readonly MemoryCache _userIdCache = new(new MemoryCacheOptions { SizeLimit = 50_000 });
private static readonly string _settingsPath = Path.Combine(AppContext.BaseDirectory, "database", "Telemetry", "settings.json");
public static volatile string[] SkipPrefixes = {
"/.well-known", "/admin/health", "/admin/ping",
"/lite/telemetry", "/telemetry", "/adminpanel", "/stats", "/weblog",
"/storage/", "/timecode/", "/bookmark/", "/tmdb/api/", "/lite/events", "/externalids", "/sisi"
};
public static volatile string[] SkipExtensions = { ".js", ".css", ".svg", ".png", ".jpg", ".woff", ".ico" };
public static volatile string[] RegexFilters = { };
private static volatile HashSet<string> _blacklistIps = new();
public static volatile int RateLimitMs = 33;
public static string[] BlacklistIps
{
get => _blacklistIps.ToArray();
set => _blacklistIps = new HashSet<string>(value, StringComparer.OrdinalIgnoreCase);
}
public static bool IsBlacklistedIp(string ip) => _blacklistIps.Contains(ip);
public static void DequeueItem() => Interlocked.Decrement(ref _queueSize);
public static Task<bool> InvokeAsync(bool first, EventMiddleware e)
{
if (first)
{
e.httpContext.Items["Telemetry_StartTime"] = DateTime.UtcNow;
return Task.FromResult(true);
}
var httpContext = e.httpContext;
var requestInfo = httpContext.Features.Get<RequestModel>();
if (requestInfo == null || requestInfo.IsLocalRequest || requestInfo.IsAnonymousRequest)
return Task.FromResult(true);
try
{
var path = httpContext.Request.Path.Value ?? "";
// Configurable Regex filters with ReDoS protection (timeout 100ms)
try
{
if (RegexFilters.Any(pattern => Regex.IsMatch(path, pattern, RegexOptions.None, TimeSpan.FromMilliseconds(100))))
return Task.FromResult(true);
}
catch (RegexMatchTimeoutException)
{
// ReDoS protection triggered, skip this request and log warning
return Task.FromResult(true);
}
// Extension filter
if (SkipExtensions.Any(ext => path.EndsWith(ext, StringComparison.OrdinalIgnoreCase)))
return Task.FromResult(true);
// Prefix filter
if (SkipPrefixes.Any(prefix => path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
return Task.FromResult(true);
var realIP = requestInfo.IP;
if (string.IsNullOrEmpty(realIP) || realIP == "127.0.0.1" || realIP == "::1")
return Task.FromResult(true);
// IP Blacklist check
if (IsBlacklistedIp(realIP))
return Task.FromResult(true);
// Rate limit
if (RateLimitMs > 0)
{
var rateLimitKey = $"{realIP}:{path}";
if (_rateLimitCache.TryGetValue(rateLimitKey, out _))
return Task.FromResult(true);
_rateLimitCache.Set(rateLimitKey, true, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(RateLimitMs),
Size = 1
});
}
string userAgent = httpContext.Request.Headers["User-Agent"].FirstOrDefault() ?? "unknown";
if (userAgent.Length > 1024) userAgent = userAgent[..1024];
string userUid = httpContext.Request.Query["uid"].FirstOrDefault() ?? httpContext.Request.Cookies["uid"] ?? "anonymous";
if (userUid.Length > 256) userUid = userUid[..256];
var cacheKey = $"{realIP}:{userAgent}";
if (!_userIdCache.TryGetValue(cacheKey, out string? userId))
{
userId = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(cacheKey))).ToLower();
_userIdCache.Set(cacheKey, userId, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(2), Size = 1 });
}
var user = new UserInfoModelSql
{
Id = userId!,
Ip = realIP,
UserAgent = userAgent
};
var fullUri = path + httpContext.Request.QueryString;
if (fullUri.Length > 2048) fullUri = fullUri[..2048];
string balancer = "";
if (path.StartsWith("/lite/")) balancer = path[6..].Split('/', '?')[0];
else if (path.StartsWith("/rc/")) balancer = path[4..].Split('/', '?')[0];
else
{
// Parse 18+ (sisi) modules which sit at the root
string[] adultRoutes = { "bgs", "chu", "elo", "epr", "hqr", "phub", "phubgay", "phubsml", "phubprem", "ptx", "runetki", "sbg", "tizam", "xmr", "xmrgay", "xmrsml", "xnx", "xds", "xdsgay", "xdssml", "xdsred", "nexthub" };
var rootSegment = path.TrimStart('/').Split('/', '?')[0].ToLowerInvariant();
if (adultRoutes.Contains(rootSegment))
{
balancer = "18+ " + rootSegment;
}
}
// Filter out system endpoints that are not actual movie balancers
string[] systemEndpoints = { "events", "sync", "auth", "ws", "subs", "vtt", "search", "suggest", "torrent", "tracker", "info", "mdl" };
if (systemEndpoints.Contains(balancer.ToLowerInvariant()))
{
balancer = "";
}
// Deduplication for balancers (First + Last within 30 seconds)
if (!string.IsNullOrEmpty(balancer))
{
var dedupKey = $"{userUid}:{balancer}";
var lastSeen = _balancerDedupCache.Get<DateTime?>(dedupKey);
if (lastSeen.HasValue && (DateTime.UtcNow - lastSeen.Value).TotalSeconds < 30)
{
return Task.FromResult(true);
}
else
{
_balancerDedupCache.Set(dedupKey, DateTime.UtcNow, new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromSeconds(30),
Size = 1
});
}
}
int durationMs = 0;
if (httpContext.Items.TryGetValue("Telemetry_StartTime", out var startTimeObj) && startTimeObj is DateTime startTime)
{
durationMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds;
}
string? mTitle = null, mTmdb = null, mKp = null, mImdb = null;
bool mIsTv = false;
var reqQuery = httpContext.Request.Query;
if (reqQuery.TryGetValue("title", out var titleVal)) mTitle = titleVal.ToString();
if (reqQuery.TryGetValue("id", out var idVal)) mTmdb = idVal.ToString();
if (reqQuery.TryGetValue("kinopoisk_id", out var kpVal)) mKp = kpVal.ToString();
if (reqQuery.TryGetValue("imdb_id", out var imdbVal)) mImdb = imdbVal.ToString();
if (reqQuery.TryGetValue("serial", out var serialVal)) mIsTv = serialVal.ToString() == "1" || serialVal.ToString().Equals("true", StringComparison.OrdinalIgnoreCase);
var log = new LogModelSql
{
Time = DateTime.UtcNow,
Uri = fullUri,
Uid = userUid,
UnfoId = user.Id,
DurationMs = durationMs,
Balancer = balancer,
StatusCode = httpContext.Response?.StatusCode ?? 0,
MovieTitle = mTitle,
TmdbId = mTmdb,
KpId = mKp,
ImdbId = mImdb,
IsTv = mIsTv
};
if (Interlocked.Increment(ref _queueSize) <= MaxQueueSize)
{
try { Queue.Enqueue((log, user)); }
catch { Interlocked.Decrement(ref _queueSize); }
}
else
{
Interlocked.Decrement(ref _queueSize);
// Dropped
}
}
catch { }
return Task.FromResult(true);
}
public static void SaveSettings()
{
try
{
var dir = Path.GetDirectoryName(_settingsPath);
if (dir != null) Directory.CreateDirectory(dir);
var data = new Dictionary<string, object>
{
["prefixes"] = SkipPrefixes,
["extensions"] = SkipExtensions,
["regexes"] = RegexFilters,
["ips"] = BlacklistIps,
["rateLimit"] = RateLimitMs
};
File.WriteAllText(_settingsPath, System.Text.Json.JsonSerializer.Serialize(data, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
}
catch (Exception ex)
{
Console.WriteLine($"[Telemetry] SaveSettings Error: {ex.Message}");
}
}
public static void LoadSettings()
{
try
{
if (!File.Exists(_settingsPath)) return;
var json = File.ReadAllText(_settingsPath);
var doc = System.Text.Json.JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("prefixes", out var p)) SkipPrefixes = p.EnumerateArray().Select(e => e.GetString()!).ToArray();
if (root.TryGetProperty("extensions", out var e2)) SkipExtensions = e2.EnumerateArray().Select(e => e.GetString()!).ToArray();
if (root.TryGetProperty("regexes", out var r)) RegexFilters = r.EnumerateArray().Select(e => e.GetString()!).ToArray();
if (root.TryGetProperty("ips", out var i)) BlacklistIps = i.EnumerateArray().Select(e => e.GetString()!).ToArray();
if (root.TryGetProperty("rateLimit", out var rl)) RateLimitMs = rl.GetInt32();
Console.WriteLine("[Telemetry] Settings loaded from disk.");
}
catch (Exception ex)
{
Console.WriteLine($"[Telemetry] LoadSettings Error: {ex.Message}");
}
}
}
+13
View File
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Telemetry</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
</ItemGroup>
</Project>
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
{
"enable": true,
"dynamic": true,
"tree": [
"Controllers",
"Models",
"AppDbContext.cs",
"ModInit.cs",
"RequestListener.cs"
]
}