Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LogUserRequest;
|
||||
|
||||
public class AppDbContext : DbContext
|
||||
{
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||
public AppDbContext() : base() { }
|
||||
|
||||
public DbSet<LogModelSql> jurnal { get; set; }
|
||||
public DbSet<UserInfoModelSql> unfo { get; set; }
|
||||
public DbSet<HeaderModelSql> headers { get; set; }
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
if (!optionsBuilder.IsConfigured)
|
||||
{
|
||||
var dbPath = Path.Combine(AppContext.BaseDirectory, "database", "LogUserRequest", "userlog.db");
|
||||
var dbDir = Path.GetDirectoryName(dbPath);
|
||||
if (!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(x => x.uid);
|
||||
modelBuilder.Entity<LogModelSql>().HasIndex(x => x.time);
|
||||
modelBuilder.Entity<LogModelSql>().HasIndex(x => x.balancer);
|
||||
modelBuilder.Entity<LogModelSql>().HasIndex(x => new { x.uid, x.time });
|
||||
}
|
||||
}
|
||||
|
||||
public class LogModelSql
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateTime time { get; set; }
|
||||
public string uri { get; set; } = "";
|
||||
public string uid { get; set; } = "";
|
||||
public string unfo { get; set; } = "";
|
||||
public string header { get; set; } = "";
|
||||
public int duration_ms { get; set; }
|
||||
public string? balancer { get; set; }
|
||||
public int? status_code { get; set; }
|
||||
}
|
||||
|
||||
public class UserInfoModelSql
|
||||
{
|
||||
[Key] public string Id { get; set; } = "";
|
||||
public string IP { get; set; } = "";
|
||||
public string Country { get; set; } = "";
|
||||
public string UserAgent { get; set; } = "";
|
||||
}
|
||||
|
||||
public class HeaderModelSql
|
||||
{
|
||||
[Key] public string Id { get; set; } = "";
|
||||
public string HeadersJson { get; set; } = "";
|
||||
|
||||
[NotMapped]
|
||||
public Dictionary<string, string> Headers
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(HeadersJson)) return new();
|
||||
try { return JsonSerializer.Deserialize<Dictionary<string, string>>(HeadersJson) ?? new(); }
|
||||
catch { return new(); }
|
||||
}
|
||||
set
|
||||
{
|
||||
var safe = (value ?? new Dictionary<string, string>())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Key))
|
||||
.Take(50)
|
||||
.ToDictionary(
|
||||
x => x.Key.Length > 100 ? x.Key[..100] : x.Key,
|
||||
x =>
|
||||
{
|
||||
var v = x.Value ?? "";
|
||||
return v.Length > 500 ? v[..500] : v;
|
||||
});
|
||||
HeadersJson = JsonSerializer.Serialize(safe);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using LogUserRequest.Models.DTO;
|
||||
|
||||
namespace LogUserRequest.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("lite/logrequest")]
|
||||
public class ApiController : ControllerBase
|
||||
{
|
||||
private static readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
|
||||
private readonly IDbContextFactory<AppDbContext> _contextFactory;
|
||||
|
||||
public ApiController(IDbContextFactory<AppDbContext> contextFactory)
|
||||
{
|
||||
_contextFactory = contextFactory;
|
||||
}
|
||||
|
||||
private static string GetAttemptsKey(string ip) => $"LogUserRequest:auth:IP:{ip}";
|
||||
|
||||
private bool IsAuthorized()
|
||||
{
|
||||
var sessionToken = Request.Cookies["loguser_session"];
|
||||
if (!string.IsNullOrEmpty(sessionToken) && ModInit.ValidateSessionToken(sessionToken))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult Index()
|
||||
{
|
||||
if (!IsAuthorized()) return Redirect("/lite/logrequest/auth");
|
||||
var htmlPath = Path.Combine(ModInit.init.path, "index.html");
|
||||
if (!System.IO.File.Exists(htmlPath)) return Content("UI missing", "text/plain");
|
||||
var html = System.IO.File.ReadAllText(htmlPath, Encoding.UTF8);
|
||||
return Content(html, "text/html; charset=utf-8");
|
||||
}
|
||||
|
||||
[HttpGet("auth")]
|
||||
public IActionResult Auth()
|
||||
{
|
||||
var sessionToken = Request.Cookies["loguser_session"];
|
||||
|
||||
if (!string.IsNullOrEmpty(sessionToken) && ModInit.ValidateSessionToken(sessionToken))
|
||||
return Redirect("/lite/logrequest");
|
||||
|
||||
var htmlPath = Path.Combine(ModInit.init.path, "auth.html");
|
||||
if (!System.IO.File.Exists(htmlPath)) return Content("Auth page missing", "text/plain");
|
||||
|
||||
Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0";
|
||||
Response.Headers["Pragma"] = "no-cache";
|
||||
Response.Headers["Expires"] = "0";
|
||||
|
||||
var html = System.IO.File.ReadAllText(htmlPath, Encoding.UTF8);
|
||||
return Content(html, "text/html; charset=utf-8");
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login([FromForm] string password)
|
||||
{
|
||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
var cacheKey = GetAttemptsKey(ip);
|
||||
var attempts = _cache.Get<int?>(cacheKey) ?? 0;
|
||||
|
||||
if (attempts > 0) await Task.Delay(attempts * 200);
|
||||
if (attempts >= 5) return Redirect("/lite/logrequest/auth?blocked=1");
|
||||
|
||||
if (password == ModInit.conf.adminPassword)
|
||||
{
|
||||
_cache.Remove(cacheKey);
|
||||
var sessionToken = ModInit.CreateSessionToken();
|
||||
|
||||
Response.Cookies.Append("loguser_session", sessionToken, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = Request.IsHttps,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
MaxAge = TimeSpan.FromDays(30),
|
||||
Path = "/lite/logrequest",
|
||||
IsEssential = true
|
||||
});
|
||||
|
||||
var script = @"
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset='utf-8'></head><body>
|
||||
<script>
|
||||
if (window.history && window.history.replaceState) {
|
||||
window.history.replaceState(null, '', '/lite/logrequest');
|
||||
}
|
||||
window.location.replace('/lite/logrequest');
|
||||
</script>
|
||||
</body></html>";
|
||||
return Content(script, "text/html; charset=utf-8");
|
||||
}
|
||||
|
||||
attempts++;
|
||||
_cache.Set(cacheKey, attempts, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpiration = DateTime.Today.AddDays(1),
|
||||
SlidingExpiration = TimeSpan.FromHours(1)
|
||||
});
|
||||
await Task.Delay(Random.Shared.Next(300, 800));
|
||||
return Redirect($"/lite/logrequest/auth?error=1&remaining={5 - attempts}");
|
||||
}
|
||||
|
||||
[HttpGet("logout")]
|
||||
public IActionResult Logout()
|
||||
{
|
||||
var sessionToken = Request.Cookies["loguser_session"];
|
||||
if (!string.IsNullOrEmpty(sessionToken)) ModInit.RevokeSessionToken(sessionToken);
|
||||
Response.Cookies.Delete("loguser_session", new CookieOptions
|
||||
{
|
||||
Path = "/lite/logrequest"
|
||||
});
|
||||
return Redirect("/lite/logrequest/auth");
|
||||
}
|
||||
|
||||
[HttpGet("api")]
|
||||
public async Task<IActionResult> Api(string? uid, int skip = 0, int take = 200)
|
||||
{
|
||||
if (!IsAuthorized()) return UnauthorizedResponse();
|
||||
take = Math.Min(take, 500);
|
||||
|
||||
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||
var query = dbContext.jurnal.AsNoTracking();
|
||||
if (!string.IsNullOrEmpty(uid)) query = query.Where(j => j.uid == uid);
|
||||
|
||||
var jurnal = await query.OrderByDescending(x => x.Id).Skip(skip).Take(take).ToListAsync();
|
||||
if (jurnal.Count == 0) return Ok(ApiResponse<List<JournalItemDto>>.Fail("Empty"));
|
||||
|
||||
var unfoIds = jurnal.Select(j => j.unfo).Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList();
|
||||
var unfoDict = await dbContext.unfo.Where(u => unfoIds.Contains(u.Id)).ToDictionaryAsync(u => u.Id);
|
||||
|
||||
var result = jurnal.Select(j =>
|
||||
{
|
||||
unfoDict.TryGetValue(j.unfo ?? "", out var unfo);
|
||||
return new JournalItemDto
|
||||
{
|
||||
Id = j.Id,
|
||||
Time = j.time,
|
||||
Uri = j.uri,
|
||||
UserUid = j.uid,
|
||||
Ip = unfo?.IP ?? "unknown",
|
||||
Country = unfo?.Country ?? "",
|
||||
UserAgent = unfo?.UserAgent ?? "unknown",
|
||||
DurationMs = j.duration_ms,
|
||||
Balancer = j.balancer
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return Ok(ApiResponse<List<JournalItemDto>>.Ok(result));
|
||||
}
|
||||
|
||||
[HttpGet("premium-chart")]
|
||||
public async Task<IActionResult> PremiumChart(int days = 7)
|
||||
{
|
||||
if (!IsAuthorized()) return UnauthorizedResponse();
|
||||
|
||||
days = Math.Min(days, 3);
|
||||
|
||||
var startDate = DateTime.UtcNow.AddDays(-days);
|
||||
|
||||
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||
var data = await dbContext.jurnal
|
||||
.Where(j => j.time >= startDate)
|
||||
.GroupBy(j => j.time.Date)
|
||||
.Select(g => new { date = g.Key, count = g.Count() })
|
||||
.OrderBy(x => x.date)
|
||||
.ToListAsync();
|
||||
|
||||
var result = new
|
||||
{
|
||||
data = data,
|
||||
message = "В Lite-версии доступно только 2 дня. Premium — до 90 дней.",
|
||||
premiumAvailable = true
|
||||
};
|
||||
|
||||
return Ok(ApiResponse<object>.Ok(result));
|
||||
}
|
||||
|
||||
[HttpGet("stats")]
|
||||
public async Task<IActionResult> Stats(string? uid)
|
||||
{
|
||||
if (!IsAuthorized()) return UnauthorizedResponse();
|
||||
|
||||
await using var dbContext = await _contextFactory.CreateDbContextAsync();
|
||||
var query = dbContext.jurnal.AsNoTracking();
|
||||
if (!string.IsNullOrEmpty(uid)) query = query.Where(j => j.uid == uid);
|
||||
|
||||
var userLogs = await query.OrderByDescending(j => j.time).Take(50000).ToListAsync();
|
||||
if (userLogs.Count == 0) return Ok(ApiResponse<StatsDto>.Fail("Empty"));
|
||||
|
||||
var unfoIds = userLogs.Select(j => j.unfo).Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList();
|
||||
var unfoList = await dbContext.unfo.Where(u => unfoIds.Contains(u.Id)).ToListAsync();
|
||||
|
||||
var uniqueIp = unfoList.Select(u => u.IP).Where(ip => !string.IsNullOrEmpty(ip)).Distinct().Count();
|
||||
var uniqueUserAgent = unfoList.Select(u => u.UserAgent).Where(ua => !string.IsNullOrEmpty(ua)).Distinct().Count();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var today = userLogs.Count(l => l.time.Day == now.Day && l.time.Month == now.Month && l.time.Year == now.Year);
|
||||
var month = userLogs.Count(l => l.time.Month == now.Month && l.time.Year == now.Year);
|
||||
|
||||
var topBalancers = userLogs
|
||||
.GroupBy(l => l.balancer)
|
||||
.Where(g => !string.IsNullOrEmpty(g.Key))
|
||||
.Select(g => new TopItemDto { Name = g.Key ?? "", Count = g.Count() })
|
||||
.OrderByDescending(x => x.Count).Take(50).ToArray();
|
||||
|
||||
var topUsers = userLogs
|
||||
.GroupBy(j => j.uid)
|
||||
.Select(g => new TopItemDto { Name = g.Key, Count = g.Count() })
|
||||
.OrderByDescending(x => x.Count).Take(20).ToArray();
|
||||
|
||||
return Ok(ApiResponse<StatsDto>.Ok(new StatsDto
|
||||
{
|
||||
Today = today,
|
||||
Month = month,
|
||||
UniqueIp = uniqueIp,
|
||||
UniqueUserAgent = uniqueUserAgent,
|
||||
TopUsers = topUsers,
|
||||
TopBalancers = topBalancers
|
||||
}));
|
||||
}
|
||||
|
||||
private IActionResult UnauthorizedResponse() => StatusCode(401, ApiResponse<object>.Fail("Unauthorized"));
|
||||
private IActionResult Ok<T>(ApiResponse<T> response) => new JsonResult(response);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
global using Microsoft.AspNetCore.Authorization;
|
||||
global using Microsoft.AspNetCore.Builder;
|
||||
global using Microsoft.AspNetCore.Http;
|
||||
global using Microsoft.AspNetCore.Mvc;
|
||||
global using Microsoft.EntityFrameworkCore;
|
||||
global using Microsoft.Extensions.Caching.Memory;
|
||||
global using Microsoft.Extensions.DependencyInjection;
|
||||
global using Shared;
|
||||
global using Shared.Attributes;
|
||||
global using Shared.Models;
|
||||
global using Shared.Models.Base;
|
||||
global using Shared.Models.Events;
|
||||
global using Shared.Models.Module;
|
||||
global using Shared.Models.Module.Interfaces;
|
||||
global using System;
|
||||
global using System.Collections.Concurrent;
|
||||
global using System.Collections.Generic;
|
||||
global using System.IO;
|
||||
global using System.Linq;
|
||||
global using System.Security.Cryptography;
|
||||
global using System.Text;
|
||||
global using System.Text.Json;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>Library</OutputType>
|
||||
<IsPackable>true</IsPackable>
|
||||
<RootNamespace>LogUserRequest</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Shared\Shared.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,182 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace LogUserRequest;
|
||||
|
||||
public static class LogUserRequestListener
|
||||
{
|
||||
public static readonly ConcurrentQueue<(LogModelSql jurnal, UserInfoModelSql unfo, HeaderModelSql header)> Queue = new();
|
||||
private static int _queueSize = 0;
|
||||
private const int MaxQueueSize = 20000;
|
||||
private static long _lastQueueOverflowLog = 0;
|
||||
private static long _lastErrorLog = 0;
|
||||
private static readonly MemoryCache _rateLimitCache = new(new MemoryCacheOptions { SizeLimit = 100_000 });
|
||||
|
||||
private static readonly string[] _skipPrefixes =
|
||||
{
|
||||
"/.well-known",
|
||||
"/admin/health",
|
||||
"/admin/ping",
|
||||
"/testaccsdb",
|
||||
"/nws",
|
||||
"/lifeevents",
|
||||
"/proxyimg",
|
||||
"/lite/logrequest",
|
||||
"/lite/events",
|
||||
"/nexthub",
|
||||
"/externalids",
|
||||
"/lampa-main",
|
||||
"/lite/withsearch",
|
||||
"/timecode",
|
||||
"/bookmark",
|
||||
"/storage",
|
||||
"/cub",
|
||||
"/cub/",
|
||||
"/sisi/bookmarks",
|
||||
"/proxy/"
|
||||
};
|
||||
|
||||
private static readonly string[] _skipExtensions =
|
||||
{
|
||||
".js", ".css", ".svg", ".png", ".jpg", ".jpeg",
|
||||
".woff", ".woff2", ".ogg", ".ico", ".map"
|
||||
};
|
||||
|
||||
public static void DequeueItem() => Interlocked.Decrement(ref _queueSize);
|
||||
|
||||
private static bool ShouldLogError()
|
||||
{
|
||||
var nowTicks = DateTime.UtcNow.Ticks;
|
||||
var last = Interlocked.Read(ref _lastErrorLog);
|
||||
if (nowTicks - last > TimeSpan.FromMinutes(1).Ticks)
|
||||
{
|
||||
Interlocked.Exchange(ref _lastErrorLog, nowTicks);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static Task<bool> InvokeAsync(bool first, EventMiddleware e)
|
||||
{
|
||||
if (first)
|
||||
return Task.FromResult(true);
|
||||
|
||||
var httpContext = e.httpContext;
|
||||
var requestInfo = httpContext.Features.Get<RequestModel>();
|
||||
|
||||
if (requestInfo == null)
|
||||
return Task.FromResult(true);
|
||||
|
||||
if (requestInfo.IsLocalRequest || requestInfo.IsAnonymousRequest)
|
||||
return Task.FromResult(true);
|
||||
|
||||
try
|
||||
{
|
||||
var path = httpContext.Request.Path.Value ?? "";
|
||||
|
||||
// Отсев статики по расширениям
|
||||
foreach (var ext in _skipExtensions)
|
||||
{
|
||||
if (path.EndsWith(ext, StringComparison.OrdinalIgnoreCase))
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
// Отсев по префиксам
|
||||
foreach (var prefix in _skipPrefixes)
|
||||
{
|
||||
if (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);
|
||||
|
||||
if (_rateLimitCache.TryGetValue(realIP, out _))
|
||||
return Task.FromResult(true);
|
||||
|
||||
_rateLimitCache.Set(realIP, DateTime.UtcNow, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(33),
|
||||
Size = 1
|
||||
});
|
||||
|
||||
string userAgent = httpContext.Request.Headers["User-Agent"].FirstOrDefault() ?? "unknown";
|
||||
if (userAgent.Length > 512) userAgent = userAgent[..512] + "...[truncated]";
|
||||
|
||||
string userUid = httpContext.Request.Query["uid"].FirstOrDefault() ?? "";
|
||||
if (string.IsNullOrEmpty(userUid)) userUid = httpContext.Request.Cookies["uid"] ?? "";
|
||||
if (string.IsNullOrEmpty(userUid)) userUid = "anonymous";
|
||||
if (userUid.Length > 256) userUid = userUid[..256];
|
||||
|
||||
string country = "";
|
||||
if (httpContext.Request.Headers.TryGetValue("CF-IPCountry", out var cfCountry))
|
||||
country = cfCountry.ToString();
|
||||
|
||||
static string GetHash(string input)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLower();
|
||||
}
|
||||
|
||||
var unfo = new UserInfoModelSql
|
||||
{
|
||||
Id = GetHash($"{realIP}:{country}:{userAgent}"),
|
||||
IP = realIP,
|
||||
Country = country,
|
||||
UserAgent = userAgent
|
||||
};
|
||||
|
||||
var header = new HeaderModelSql { Headers = new Dictionary<string, string>() };
|
||||
header.Id = "";
|
||||
|
||||
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];
|
||||
|
||||
var jurnal = new LogModelSql
|
||||
{
|
||||
time = DateTime.UtcNow,
|
||||
uri = fullUri,
|
||||
uid = userUid,
|
||||
duration_ms = 0,
|
||||
balancer = balancer,
|
||||
status_code = httpContext.Response?.StatusCode ?? 0
|
||||
};
|
||||
|
||||
if (Interlocked.Increment(ref _queueSize) <= MaxQueueSize)
|
||||
{
|
||||
try { Queue.Enqueue((jurnal, unfo, header)); }
|
||||
catch { Interlocked.Decrement(ref _queueSize); }
|
||||
}
|
||||
else
|
||||
{
|
||||
Interlocked.Decrement(ref _queueSize);
|
||||
var nowTicks = DateTime.UtcNow.Ticks;
|
||||
var last = Interlocked.Read(ref _lastQueueOverflowLog);
|
||||
if (nowTicks - last > TimeSpan.FromMinutes(1).Ticks)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _lastQueueOverflowLog, nowTicks, last) == last)
|
||||
{
|
||||
Console.WriteLine($"[LogUserRequest] Queue overflow, dropping logs. Size: {_queueSize}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ShouldLogError())
|
||||
Console.WriteLine($"[LogUserRequest] Listener error: {ex}");
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace LogUserRequest;
|
||||
|
||||
public class ModInit : IModuleLoaded, IModuleConfigure
|
||||
{
|
||||
public static InitspaceModel init { get; set; } = null!;
|
||||
public static (int logDay, string adminPassword) conf = (90, "");
|
||||
public static object? stats = null;
|
||||
static Timer? _statsTimer, _clearJurnalTimer, _updateDbTimer;
|
||||
|
||||
private static int _updatingStats = 0;
|
||||
private static int _updatingDb = 0;
|
||||
private static readonly MemoryCache _sessionTokens = new(new MemoryCacheOptions { SizeLimit = 10000 });
|
||||
private static readonly TimeSpan _sessionLifetime = TimeSpan.FromDays(30);
|
||||
|
||||
private static string _workPath = AppContext.BaseDirectory;
|
||||
private static string _dbDirectory = Path.Combine(AppContext.BaseDirectory, "database", "LogUserRequest");
|
||||
private static string _dbPath = Path.Combine(AppContext.BaseDirectory, "database", "LogUserRequest", "userlog.db");
|
||||
private static string _passwdPath = Path.Combine(AppContext.BaseDirectory, "database", "LogUserRequest", "passlogreg");
|
||||
|
||||
// === IModuleConfigure ===
|
||||
public void Configure(ConfigureModel app)
|
||||
{
|
||||
app.services.AddDbContextFactory<AppDbContext>(options =>
|
||||
{
|
||||
options.UseSqlite($"Data Source={_dbPath};Cache=Shared");
|
||||
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
|
||||
});
|
||||
}
|
||||
|
||||
public static bool ValidateSessionToken(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) return false;
|
||||
return _sessionTokens.TryGetValue(token, out _);
|
||||
}
|
||||
|
||||
public static string CreateSessionToken()
|
||||
{
|
||||
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
||||
_sessionTokens.Set(token, true, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = _sessionLifetime,
|
||||
SlidingExpiration = TimeSpan.FromDays(7),
|
||||
Size = 1
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
public static void RevokeSessionToken(string token) => _sessionTokens.Remove(token);
|
||||
|
||||
private static string GenerateRandomPassword(int length = 36)
|
||||
{
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
var bytes = new byte[length];
|
||||
rng.GetBytes(bytes);
|
||||
var base64 = Convert.ToBase64String(bytes);
|
||||
return base64.Replace('+', 'A').Replace('/', 'B')[..length];
|
||||
}
|
||||
|
||||
private static string GetOrCreateAdminPassword()
|
||||
{
|
||||
var envPassword = Environment.GetEnvironmentVariable("LOGUSER_ADMIN_PASSWORD");
|
||||
if (!string.IsNullOrEmpty(envPassword))
|
||||
return envPassword;
|
||||
|
||||
if (File.Exists(_passwdPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var filePassword = File.ReadAllText(_passwdPath).Trim();
|
||||
if (!string.IsNullOrEmpty(filePassword))
|
||||
return filePassword;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
var oldPasswdPath = Path.Combine(_workPath, "passlogreg");
|
||||
if (File.Exists(oldPasswdPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var filePassword = File.ReadAllText(oldPasswdPath).Trim();
|
||||
if (!string.IsNullOrEmpty(filePassword))
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_passwdPath)!);
|
||||
File.Move(oldPasswdPath, _passwdPath);
|
||||
return filePassword;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
var newPassword = GenerateRandomPassword(36);
|
||||
try
|
||||
{
|
||||
var passwdDir = Path.GetDirectoryName(_passwdPath);
|
||||
if (!string.IsNullOrEmpty(passwdDir))
|
||||
Directory.CreateDirectory(passwdDir);
|
||||
|
||||
File.WriteAllText(_passwdPath, newPassword);
|
||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD())
|
||||
{
|
||||
try { File.SetUnixFileMode(_passwdPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { }
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.WriteLine("[LogUserRequest-Lite] Warning: Cannot save password to file. Using memory-only password.");
|
||||
}
|
||||
|
||||
return newPassword;
|
||||
}
|
||||
|
||||
public void Loaded(InitspaceModel initspace)
|
||||
{
|
||||
init = initspace;
|
||||
|
||||
_workPath = AppContext.BaseDirectory;
|
||||
_dbDirectory = Path.Combine(_workPath, "database", "LogUserRequest");
|
||||
_dbPath = Path.Combine(_dbDirectory, "userlog.db");
|
||||
_passwdPath = Path.Combine(_dbDirectory, "passlogreg");
|
||||
|
||||
Console.WriteLine($"[LogUserRequest-Lite] Work path: {_workPath}");
|
||||
Console.WriteLine($"[LogUserRequest-Lite] DB path: {_dbPath}");
|
||||
Console.WriteLine($"[LogUserRequest-Lite] Password path: {_passwdPath}");
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_dbDirectory);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
Console.WriteLine($"[LogUserRequest-Lite] Warning: Cannot create directory {_dbDirectory}: {ex.Message}");
|
||||
}
|
||||
|
||||
using (var sqlDb = new AppDbContext())
|
||||
{
|
||||
sqlDb.Database.EnsureCreated();
|
||||
try { sqlDb.Database.ExecuteSqlRaw("ALTER TABLE jurnal ADD COLUMN balancer TEXT;"); } catch { }
|
||||
try { sqlDb.Database.ExecuteSqlRaw("ALTER TABLE jurnal ADD COLUMN status_code INTEGER NULL;"); } catch { }
|
||||
try { sqlDb.Database.ExecuteSqlRaw("ALTER TABLE jurnal ADD COLUMN duration_ms INTEGER DEFAULT 0;"); } catch { }
|
||||
try { sqlDb.Database.ExecuteSqlRaw("CREATE INDEX IF NOT EXISTS IX_jurnal_uid ON jurnal(uid);"); } catch { }
|
||||
try { sqlDb.Database.ExecuteSqlRaw("CREATE INDEX IF NOT EXISTS IX_jurnal_time ON jurnal(time);"); } catch { }
|
||||
|
||||
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 { }
|
||||
}
|
||||
|
||||
var manifestPath = Path.Combine(init.path, "manifest.json");
|
||||
JObject manifest = new();
|
||||
if (File.Exists(manifestPath))
|
||||
{
|
||||
manifest = JsonConvert.DeserializeObject<JObject>(File.ReadAllText(manifestPath)) ?? new();
|
||||
conf.logDay = manifest["logDay"]?.Value<int>() ?? 90;
|
||||
}
|
||||
|
||||
conf.adminPassword = GetOrCreateAdminPassword();
|
||||
|
||||
_clearJurnalTimer = new Timer(ClearJurnal, null, TimeSpan.FromMinutes(1), TimeSpan.FromHours(24));
|
||||
_statsTimer = new Timer(UpdateStatsCallback, null, TimeSpan.FromSeconds(10), TimeSpan.FromMinutes(5));
|
||||
_updateDbTimer = new Timer(UpdateDbCallback, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(30));
|
||||
|
||||
// === Подписка на EventListener вместо app.UseMiddleware ===
|
||||
EventListener.MiddlewareAsync -= LogUserRequestListener.InvokeAsync;
|
||||
EventListener.MiddlewareAsync += LogUserRequestListener.InvokeAsync;
|
||||
|
||||
Console.WriteLine($"[LogUserRequest-Lite] Module loaded (logDay={conf.logDay})");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// === Отписка ===
|
||||
EventListener.MiddlewareAsync -= LogUserRequestListener.InvokeAsync;
|
||||
|
||||
_clearJurnalTimer?.Dispose();
|
||||
_statsTimer?.Dispose();
|
||||
_updateDbTimer?.Dispose();
|
||||
_sessionTokens.Dispose();
|
||||
}
|
||||
|
||||
// ... остальные методы (ClearJurnal, UpdateStats, UpdateDb) без изменений
|
||||
static void ClearJurnal(object? state)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var sqlDb = new AppDbContext();
|
||||
var cutoff = DateTime.UtcNow.AddDays(-conf.logDay);
|
||||
|
||||
const int batchSize = 5000;
|
||||
int totalDeleted = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var idsToDelete = sqlDb.jurnal
|
||||
.Where(j => j.time < cutoff)
|
||||
.OrderBy(j => j.Id)
|
||||
.Select(j => j.Id)
|
||||
.Take(batchSize)
|
||||
.ToList();
|
||||
|
||||
if (idsToDelete.Count == 0) break;
|
||||
|
||||
sqlDb.jurnal.Where(j => idsToDelete.Contains(j.Id)).ExecuteDelete();
|
||||
totalDeleted += idsToDelete.Count;
|
||||
|
||||
if (idsToDelete.Count < batchSize) break;
|
||||
}
|
||||
|
||||
if (totalDeleted > 0)
|
||||
{
|
||||
var usedUnfo = sqlDb.jurnal.Select(j => j.unfo).Distinct().Take(50000).ToHashSet();
|
||||
var usedHeaders = sqlDb.jurnal.Select(j => j.header).Distinct().Take(50000).ToHashSet();
|
||||
|
||||
sqlDb.unfo.Where(u => !usedUnfo.Contains(u.Id)).Take(batchSize).ExecuteDelete();
|
||||
sqlDb.headers.Where(h => !usedHeaders.Contains(h.Id)).Take(batchSize).ExecuteDelete();
|
||||
|
||||
Console.WriteLine($"[LogUserRequest-Lite] Cleaned {totalDeleted} old records");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[LogUserRequest-Lite] ClearJurnal error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateStatsCallback(object? state) => UpdateStats();
|
||||
|
||||
public static void UpdateStats()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _updatingStats, 1) == 1) return;
|
||||
try
|
||||
{
|
||||
using var sqlDb = new AppDbContext();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var monthStart = new DateTime(now.Year, now.Month, 1);
|
||||
var todayStart = new DateTime(now.Year, now.Month, now.Day);
|
||||
var tomorrowStart = todayStart.AddDays(1);
|
||||
|
||||
var monthQuery = sqlDb.jurnal.Where(j => j.time >= monthStart);
|
||||
|
||||
int today = monthQuery.Count(j => j.time >= todayStart && j.time < tomorrowStart);
|
||||
int month = monthQuery.Count();
|
||||
|
||||
var unfoIds = monthQuery.Select(j => j.unfo).Distinct().Take(10000).ToList();
|
||||
|
||||
if (unfoIds.Count > 0)
|
||||
{
|
||||
var unfoData = sqlDb.unfo
|
||||
.Where(u => unfoIds.Contains(u.Id))
|
||||
.Select(u => new { u.IP, u.UserAgent })
|
||||
.Take(10000)
|
||||
.ToList();
|
||||
|
||||
int uniqueUserAgent = unfoData.Select(u => u.UserAgent).Distinct().Count();
|
||||
int uniqueIp = unfoData.Select(u => u.IP).Where(ip => !string.IsNullOrEmpty(ip)).Distinct().Count();
|
||||
|
||||
var topUsers = monthQuery
|
||||
.GroupBy(j => j.uid)
|
||||
.Select(g => new { uid = g.Key, count = g.Count() })
|
||||
.OrderByDescending(x => x.count)
|
||||
.Take(20)
|
||||
.ToArray();
|
||||
|
||||
var topBalancers = monthQuery
|
||||
.Where(j => j.balancer != null)
|
||||
.GroupBy(j => j.balancer)
|
||||
.Select(g => new { balancer = g.Key, count = g.Count() })
|
||||
.OrderByDescending(x => x.count)
|
||||
.Take(50)
|
||||
.ToArray();
|
||||
|
||||
stats = new { today, month, uniqueUserAgent, uniqueIp, topUsers, topBalancers };
|
||||
}
|
||||
else
|
||||
{
|
||||
stats = new
|
||||
{
|
||||
today = 0,
|
||||
month = 0,
|
||||
uniqueUserAgent = 0,
|
||||
uniqueIp = 0,
|
||||
topUsers = Array.Empty<object>(),
|
||||
topBalancers = Array.Empty<object>()
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[LogUserRequest-Lite] UpdateStats error: {ex.Message}");
|
||||
}
|
||||
finally { Interlocked.Exchange(ref _updatingStats, 0); }
|
||||
}
|
||||
|
||||
private static void UpdateDbCallback(object? state) => UpdateDb();
|
||||
|
||||
static void UpdateDb()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _updatingDb, 1) == 1) return;
|
||||
try
|
||||
{
|
||||
using var sqlDb = new AppDbContext();
|
||||
sqlDb.ChangeTracker.AutoDetectChangesEnabled = false;
|
||||
|
||||
var batch = new List<(LogModelSql jurnal, UserInfoModelSql unfo, HeaderModelSql header)>();
|
||||
|
||||
int batchSize = 1000;
|
||||
while (LogUserRequestListener.Queue.TryDequeue(out var item) && batch.Count < batchSize)
|
||||
{
|
||||
batch.Add(item);
|
||||
LogUserRequestListener.DequeueItem();
|
||||
}
|
||||
|
||||
if (batch.Count == 0) return;
|
||||
|
||||
var unfoIds = batch.Select(b => b.unfo.Id).Distinct().ToList();
|
||||
var headerIds = batch.Select(b => b.header.Id).Distinct().ToList();
|
||||
|
||||
var existingUnfo = sqlDb.unfo
|
||||
.Where(u => unfoIds.Contains(u.Id))
|
||||
.Select(u => u.Id)
|
||||
.ToHashSet();
|
||||
|
||||
var existingHeaders = sqlDb.headers
|
||||
.Where(h => headerIds.Contains(h.Id))
|
||||
.Select(h => h.Id)
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var item in batch.OrderBy(i => i.jurnal.time))
|
||||
{
|
||||
if (!existingUnfo.Contains(item.unfo.Id))
|
||||
{
|
||||
sqlDb.unfo.Add(item.unfo);
|
||||
existingUnfo.Add(item.unfo.Id);
|
||||
}
|
||||
if (!existingHeaders.Contains(item.header.Id))
|
||||
{
|
||||
sqlDb.headers.Add(item.header);
|
||||
existingHeaders.Add(item.header.Id);
|
||||
}
|
||||
sqlDb.jurnal.Add(new LogModelSql
|
||||
{
|
||||
time = item.jurnal.time,
|
||||
uri = item.jurnal.uri,
|
||||
uid = item.jurnal.uid,
|
||||
unfo = item.unfo.Id,
|
||||
header = item.header.Id,
|
||||
duration_ms = item.jurnal.duration_ms,
|
||||
balancer = item.jurnal.balancer,
|
||||
status_code = item.jurnal.status_code
|
||||
});
|
||||
}
|
||||
|
||||
sqlDb.SaveChanges();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[LogUserRequest-Lite] UpdateDb error: {ex.Message}");
|
||||
}
|
||||
finally { Interlocked.Exchange(ref _updatingDb, 0); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace LogUserRequest.Models.DTO;
|
||||
|
||||
public class ApiResponse<T>
|
||||
{
|
||||
public bool Success { get; set; } = true;
|
||||
public string? Error { get; set; }
|
||||
public T? Data { get; set; }
|
||||
|
||||
public static ApiResponse<T> Ok(T data) => new() { Success = true, Data = data };
|
||||
public static ApiResponse<T> Fail(string error) => new() { Success = false, Error = error };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace LogUserRequest.Models.DTO;
|
||||
|
||||
public class JournalItemDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateTime Time { get; set; }
|
||||
public string Uri { get; set; } = "";
|
||||
public string UserUid { get; set; } = "";
|
||||
public string Ip { get; set; } = "unknown";
|
||||
public string Country { get; set; } = "";
|
||||
public string UserAgent { get; set; } = "unknown";
|
||||
public int DurationMs { get; set; }
|
||||
public string? Balancer { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace LogUserRequest.Models.DTO;
|
||||
|
||||
public class StatsDto
|
||||
{
|
||||
public int Today { get; set; }
|
||||
public int Month { get; set; }
|
||||
public int UniqueIp { get; set; }
|
||||
public int UniqueUserAgent { get; set; }
|
||||
public TopItemDto[] TopUsers { get; set; } = Array.Empty<TopItemDto>();
|
||||
public TopItemDto[] TopBalancers { get; set; } = Array.Empty<TopItemDto>();
|
||||
}
|
||||
|
||||
public class TopItemDto
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public int Count { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
## Установка
|
||||
|
||||
1. Скачайте архив с модулем
|
||||
2. Распакуйте папку `LogUserRequest-Lite` в `/lampac/mods/`
|
||||
3. Отредактируйте init.conf. Разрешите доступ к странице админки LogUserRequest-Lite:
|
||||
"accsdb": {
|
||||
"enable": true,
|
||||
"whitepattern": "^/lite/logrequest"
|
||||
},
|
||||
4. Перезапустите Lampac
|
||||
5. Админка: http://ваш_сервер/lite/logrequest
|
||||
|
||||
## Настройка модуля:
|
||||
1. Конфигурация в manifest.json:
|
||||
|
||||
{
|
||||
"enable": true,
|
||||
"version": 4,
|
||||
"namespace": "LogUserRequest",
|
||||
"logDay": 90
|
||||
}
|
||||
| Параметр | Описание | Значение по умолчанию |
|
||||
|---|---|---|
|
||||
| enable | Включить модуль | true |
|
||||
| logDay | Сколько дней хранить логи | 90 |
|
||||
|
||||
Пароль администратора:
|
||||
1. Переменная окружения (LOGUSER_ADMIN_PASSWORD=my-super-super-secret-key)
|
||||
2. Пароль генерируется автоматически при первом запуске и сохраняется в файл: /lampac/database/LogUserRequest/passlogreg
|
||||
(Посмотреть пароль: docker exec lampac cat /lampac/database/LogUserRequest/passlogreg)
|
||||
|
||||
Что произойдёт при первом запуске
|
||||
|
||||
| Ситуация | Результат |
|
||||
|---|---|
|
||||
| ENV задан | Используется ENV, файл не создаётся |
|
||||
| ENV пуст, файла нет | Создаётся /lampac/database/LogUserRequest/passlogreg со случайным паролем (36 символов) |
|
||||
| ENV пуст, файл есть | Используется пароль из файла |
|
||||
|
||||
⚠️ Важно:
|
||||
Если переменная окружения задана - она имеет наивысший приоритет, и остальные источники игнорируются.
|
||||
|
||||
Блокировка при входе в LogUserRequest
|
||||
Как работает блокировка:
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---|
|
||||
| Максимум попыток | 5 неверных паролей в день |
|
||||
| Блокировка | После 5 неудачных попыток |
|
||||
| Сброс счётчика | В полночь (по UTC) |
|
||||
| Блокируется | Только IP нарушителя (остальные пользователи не страдают) |
|
||||
|
||||
Что делать при блокировке
|
||||
1. Подождать до полуночи
|
||||
Счётчик сбрасывается автоматически в 00:00 UTC. (в коде `DateTime.Today.AddDays(1)` использует локальное время сервера, убедитесь что часовой пояс UTC)
|
||||
2. Сменить IP (если нужно срочно)
|
||||
Переподключиться к VPN
|
||||
Использовать другой интернет
|
||||
Перезагрузить роутер
|
||||
|
||||
3. Проверить правильность пароля
|
||||
docker exec lampac cat /lampac/database/LogUserRequest/passlogreg
|
||||
|
||||
4. Сбросить блокировку вручную (требуется root доступ)
|
||||
Перезапустить контейнер (сбросит кэш попыток)
|
||||
docker restart lampac
|
||||
|
||||
Сброс счётчика в полночь:
|
||||
Изменения можно внести в Controllers/ApiController.cs
|
||||
// В коде используется
|
||||
AbsoluteExpiration = DateTime.Today.AddDays(1)
|
||||
|
||||
## Инструкция по настройке фильтрации в Lite-версии
|
||||
|
||||
### Где находятся фильтры
|
||||
|
||||
Все фильтры находятся в файле `LogUserRequestListener.cs` в начале класса `LogUserRequestListener`.
|
||||
|
||||
### Как работает фильтрация
|
||||
|
||||
Модуль использует **три уровня фильтрации**:
|
||||
|
||||
1. **Автоматическая (EventListener lampac)** — отсеивает анонимные запросы, локальные IP, служебные эндпоинты ядра
|
||||
2. **Список префиксов (`_skipPrefixes`)** — пути, которые начинаются с указанных строк
|
||||
3. **Список расширений (`_skipExtensions`)** — статические файлы по расширению
|
||||
|
||||
Попадание в любой из фильтров — запрос не логируется.
|
||||
|
||||
---
|
||||
|
||||
### 1. Список пропускаемых префиксов (`_skipPrefixes`)
|
||||
|
||||
private static readonly string[] _skipPrefixes =
|
||||
{
|
||||
"/.well-known",
|
||||
"/admin/health",
|
||||
"/admin/ping",
|
||||
"/testaccsdb",
|
||||
"/nws",
|
||||
"/lifeevents",
|
||||
"/proxyimg",
|
||||
"/lite/logrequest",
|
||||
"/lite/events",
|
||||
"/nexthub",
|
||||
"/externalids",
|
||||
"/lampa-main",
|
||||
"/lite/withsearch",
|
||||
"/timecode",
|
||||
"/bookmark",
|
||||
"/storage",
|
||||
"/cub",
|
||||
"/cub/",
|
||||
"/sisi/bookmarks",
|
||||
"/proxy/"
|
||||
};
|
||||
|
||||
#### Как добавить новый путь:
|
||||
|
||||
private static readonly string[] _skipPrefixes =
|
||||
{
|
||||
// ... существующие ...
|
||||
"/ваш_путь", // Блокирует /ваш_путь и /ваш_путь/что-угодно
|
||||
"/api/internal", // Ещё один пример
|
||||
};
|
||||
|
||||
### 2. Список расширений (_skipExtensions)
|
||||
|
||||
private static readonly string[] _skipExtensions =
|
||||
{
|
||||
".js", ".css", ".svg", ".png", ".jpg", ".jpeg",
|
||||
".woff", ".woff2", ".ogg", ".ico", ".map"
|
||||
};
|
||||
|
||||
**Как добавить новое расширение:**
|
||||
|
||||
private static readonly string[] _skipExtensions =
|
||||
{
|
||||
// ... существующие ...
|
||||
".php", // Блокирует все .php файлы
|
||||
".webp", // Блокирует .webp изображения
|
||||
};
|
||||
|
||||
### 3. Rate Limit (ограничение частоты запросов)
|
||||
|
||||
_rateLimitCache.Set(realIP, DateTime.UtcNow, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(33), // ← интервал
|
||||
Size = 1
|
||||
});
|
||||
|
||||
**Как изменить интервал:**
|
||||
|
||||
TimeSpan.FromMilliseconds(33) — ~30 запросов/сек с одного IP
|
||||
TimeSpan.FromMilliseconds(100) — 10 запросов/сек
|
||||
TimeSpan.FromSeconds(1) — 1 запрос/сек
|
||||
|
||||
### 4. Максимальный размер очереди
|
||||
|
||||
private const int MaxQueueSize = 20000;
|
||||
|
||||
При превышении лимита новые записи отбрасываются с throttled-логом (не чаще раза в минуту).
|
||||
|
||||
#### 📋 Примеры фильтрации
|
||||
|
||||
| Что хотите заблокировать | Куда добавить | Код |
|
||||
|---|---|---|
|
||||
| Конкретный путь /api/test | _skipPrefixes | "/api/test" |
|
||||
| Все пути с префиксом /bot/ | _skipPrefixes | "/bot" |
|
||||
| Все .php файлы | _skipExtensions | ".php" |
|
||||
| Слишком частые запросы | Rate Limit | Увеличить FromMilliseconds(33) до FromMilliseconds(100) |
|
||||
|
||||
#### Что фильтруется автоматически (EventListener lampac)
|
||||
|
||||
Это не нужно добавлять в списки — lampac делает это сам:
|
||||
Статические файлы (js, css, png, svg, woff)
|
||||
Анонимные запросы
|
||||
Локальные IP (127.0.0.1, ::1)
|
||||
Служебные эндпоинты lampac
|
||||
Запросы до авторизации
|
||||
|
||||
#### ⚠️ Важные замечания
|
||||
После изменения файла нужна перекомпиляция модуля
|
||||
Синтаксис C# чувствителен к регистру и запятым
|
||||
Не удаляйте /lite/logrequest из _skipPrefixes — иначе страница модуля будет логировать саму себя
|
||||
При ошибке в синтаксисе модуль не загрузится
|
||||
|
||||
## База данных
|
||||
Путь: /lampac/database/LogUserRequest/userlog.db
|
||||
|
||||
Очистка старых записей
|
||||
В Lite-версии реализована **очистка по дням** через параметр logDay в manifest.json.
|
||||
{
|
||||
"logDay": 90
|
||||
}
|
||||
|
||||
### Как работает:
|
||||
Записи старше logDay дней удаляются раз в сутки
|
||||
Проверка запускается через 1 минуту после старта модуля и затем каждые 24 часа
|
||||
Удаление происходит батчами по 5000 записей для защиты от блокировки SQLite при больших объёмах
|
||||
После удаления старых записей также удаляются неиспользуемые unfo и headers
|
||||
|
||||
**Код в ModInit.cs:**
|
||||
|
||||
Код находится в ModInit.cs, метод ClearJurnal. Удаление происходит батчами по 5000 записей.
|
||||
|
||||
**Почему батчами:**
|
||||
SQLite блокирует всю таблицу при удалении
|
||||
При большом количестве записей (100 000+) одиночный DELETE может занять секунды
|
||||
Батчи по 5000 позволяют другим операциям (запись логов, чтение админки) работать между удалениями
|
||||
|
||||
### Рекомендуемые значения
|
||||
|
||||
| Сценарий | logDay: |
|
||||
|---|---|
|
||||
| Домашнее использование (1-5 пользователей) | 90 дней |
|
||||
| Небольшой сервер (5-20 пользователей) | 60 дней |
|
||||
| Крупный сервер (20+ пользователей) | 30 дней |
|
||||
|
||||
Оптимизация SQLite (уже настроено)
|
||||
В модуле применены:
|
||||
WAL — ускорение записи
|
||||
cache_size = -64000 — кэш 64 МБ
|
||||
temp_store = MEMORY — временные таблицы в памяти
|
||||
mmap_size = 33554432 — memory-mapped I/O (32 МБ)
|
||||
|
||||
### Ручная очистка и редактирование базы данных
|
||||
Для редактирования базы данных используйте **DB Browser for SQLite** — бесплатный визуальный редактор.
|
||||
|
||||
🔗 Скачать: [https://sqlitebrowser.org/](https://sqlitebrowser.org/)
|
||||
|
||||
Пошаговая инструкция
|
||||
|
||||
1. **Скопируйте** файл `userlog.db` из папки `lampac-docker/database/LogUserRequest/` в любое удобное место (это будет ваш бэкап)
|
||||
|
||||
2. **Отредактируйте копию** в DB Browser for SQLite:
|
||||
- Откройте скопированный файл
|
||||
- Перейдите на вкладку **"Execute SQL"**
|
||||
- Вставьте и выполните запросы:
|
||||
|
||||
DELETE FROM jurnal WHERE time < datetime('now', '-30 days');
|
||||
VACUUM;
|
||||
|
||||
Сохраните изменения (Ctrl+S)
|
||||
|
||||
Замените оригинал — скопируйте отредактированный файл обратно в lampac-docker/database/LogUserRequest/ с заменой
|
||||
Перезапустите контейнер:
|
||||
|
||||
docker restart lampac
|
||||
|
||||
✅ Готово! База очищена, старые записи удалены.
|
||||
|
||||
📋 Альтернативные запросы
|
||||
|
||||
| Задача | SQL-запрос |
|
||||
|---|---|
|
||||
| Удалить старше 7 дней | DELETE FROM jurnal WHERE time < datetime('now', '-7 days'); |
|
||||
| Удалить старше 90 дней | DELETE FROM jurnal WHERE time < datetime('now', '-90 days'); |
|
||||
| Удалить ВСЕ записи | DELETE FROM jurnal; |
|
||||
| Посмотреть количество записей | SELECT COUNT(*) FROM jurnal; |
|
||||
|
||||
## 🕐 Время записей не совпадает с местным
|
||||
По умолчанию модуль сохраняет время запросов в **UTC**. Если в админке время отличается от вашего локального — это нормально, но можно настроить.
|
||||
|
||||
Варианты решения
|
||||
1. Сменить часовой пояс контейнера (рекомендуется)
|
||||
Добавьте переменную окружения TZ при запуске контейнера:
|
||||
docker run -e TZ=Europe/…
|
||||
docker run -e TZ=Asia/…
|
||||
Или в docker-compose.yml:
|
||||
environment:
|
||||
- TZ=Europe/….
|
||||
После изменения — перезапустите контейнер.
|
||||
2. Если TZ не поддерживается нужно изменить код модуля (⚠️Не рекомендуется)
|
||||
Если нужно хранить локальное время вместо UTC, измените во всех файлах модуля
|
||||
// Было
|
||||
DateTime.UtcNow
|
||||
DateTime.UtcNow.AddDays(-X)
|
||||
DateTime.UtcNow.Date
|
||||
// Стало
|
||||
DateTime.Now
|
||||
DateTime.Now.AddDays(-X)
|
||||
DateTime.Now.Date
|
||||
|
||||
Не заменяйте:
|
||||
DateTime.Now.Ticks
|
||||
В именах файлов экспорта ($"export_{DateTime.Now:yyyyMMdd}.zip")
|
||||
|
||||
### ⚠️ Важно: последствия перехода на локальное время
|
||||
| Проблема | Описание |
|
||||
|---|---|
|
||||
| **Переход на летнее/зимнее время** | Дважды в год время сдвигается на час. Записи могут дублироваться или нарушится сортировка |
|
||||
| **Смена часового пояса сервера** | При переезде на другой сервер все исторические данные будут показывать неверное время |
|
||||
| **Сравнение логов с разных серверов** | Если у вас несколько серверов в разных часовых поясах — логи невозможно сопоставить |
|
||||
| **Очистка по `logDay`** | Может удалить на час больше или меньше записей при переходе времени |
|
||||
|
||||
#### 💡 Рекомендация: Оставьте UTC. Браузер сам покажет локальное время. Это стандарт для логирования.
|
||||
|
||||
## ❓ Частые проблемы
|
||||
|
||||
### Модуль не загружается
|
||||
- Проверьте синтаксис в `manifest.json` (запятые, кавычки)
|
||||
- Посмотрите логи: `docker logs lampac | grep LogUserRequest`
|
||||
|
||||
### Не открывается админка (404)
|
||||
- Убедитесь, что путь `/lite/logrequest` не заблокирован в чёрном списке
|
||||
- Проверьте, что модуль включён: `"enable": true`
|
||||
|
||||
### Пароль не подходит
|
||||
- ENV переменная имеет приоритет: `docker exec lampac env | grep LOGUSER`
|
||||
- Проверьте файл: `docker exec lampac cat /lampac/database/LogUserRequest/passlogreg`
|
||||
|
||||
### База данных растёт слишком быстро
|
||||
- Уменьшите `logDay` до 30
|
||||
- Добавьте в чёрный список часто вызываемые служебные пути
|
||||
|
||||
### После входа в админку белый экран
|
||||
- Проверьте консоль браузера (F12) на ошибки
|
||||
- Убедитесь, что файлы `index.html` и `auth.html` не повреждены
|
||||
- Очистите кэш браузера
|
||||
|
||||
### Не работает кнопка "Premium"
|
||||
- Это не баг 😄 — кнопка открывает пасхалку и информацию о Premium-версии
|
||||
- Lite-версия полностью бесплатна и функциональна
|
||||
|
||||
💡 **Premium-версия** добавляет графики, экспорт, Telegram-уведомления и гибкие фильтры через UI.
|
||||
Подробнее — в модальном окне по кнопке "Premium" в админке или у разработчика.
|
||||
|
||||
## 📞 Поддержка
|
||||
|
||||
По вопросам работы модуля, предложениям и багам:
|
||||
|
||||
- Telegram: [@Viacheslav_Sh1](https://t.me/Viacheslav_Sh1)
|
||||
|
||||
---
|
||||
|
||||
**LogUserRequest Lite** — бесплатный модуль для логирования запросов в Lampac NG.
|
||||
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0c0e12" id="metaTheme">
|
||||
<title>LogUserRequest — Вход</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root { --font-body: 'Inter', system-ui, -apple-system, sans-serif; }
|
||||
[data-theme="dark"] { --bg: #0c0e12; --surface: #13161d; --surface-2: #1a1e28; --border: #2a3040; --text: #e2e6ef; --text-muted: #7c849a; --accent: #3ec9d1; --accent-soft: #2ea8b0; --err: #f87171; --err-dim: rgba(248, 113, 113, 0.12); --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.45); }
|
||||
[data-theme="light"] { --bg: #f4f5f8; --surface: #ffffff; --surface-2: #f0f1f5; --border: #d4d8e3; --text: #1a1e28; --text-muted: #6b7280; --accent: #0d9499; --accent-soft: #0b7d82; --err: #dc2626; --err-dim: rgba(220, 38, 38, 0.1); --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.12); }
|
||||
html, body { height: 100%; }
|
||||
body { font-family: var(--font-body); background: var(--bg); display: flex; justify-content: center; align-items: center; color: var(--text); -webkit-font-smoothing: antialiased; }
|
||||
.auth-container { background: var(--surface); border: 1px solid var(--border); border-radius: 24px; padding: 48px 40px; width: 100%; max-width: 420px; box-shadow: var(--shadow-lg); }
|
||||
.logo { display: flex; align-items: center; justify-content: center; gap: 8px; margin-bottom: 32px; }
|
||||
.logo svg { width: 36px; height: 36px; }
|
||||
.logo span { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text); }
|
||||
.logo small { font-weight: 400; color: var(--text-muted); margin-left: 4px; }
|
||||
h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 24px; text-align: center; color: var(--accent); }
|
||||
.error { background: var(--err-dim); border: 1px solid var(--err); border-radius: 10px; padding: 12px 16px; margin-bottom: 20px; color: var(--err); font-size: 0.9rem; display: none; }
|
||||
.error.show { display: block; }
|
||||
input { width: 100%; background: var(--surface-2); border: 1px solid var(--border); border-radius: 12px; padding: 14px 18px; color: var(--text); font-size: 1rem; margin-bottom: 24px; transition: border 0.15s; }
|
||||
input:focus { outline: none; border-color: var(--accent); }
|
||||
button { width: 100%; background: var(--accent); border: none; border-radius: 12px; padding: 14px; color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; transition: background 0.15s; }
|
||||
button:hover { background: var(--accent-soft); }
|
||||
.theme-toggle { position: absolute; top: 20px; right: 20px; width: 40px; height: 40px; border-radius: 10px; background: var(--surface-2); border: 1px solid var(--border); color: var(--text-muted); cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||
.theme-toggle:hover { color: var(--text); }
|
||||
.theme-toggle svg { width: 20px; height: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="theme-toggle" id="themeBtn" title="Сменить тему (Alt+T)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="auth-container">
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true">
|
||||
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5" fill="none" />
|
||||
<rect x="7" y="16" width="3" height="6" rx="1" fill="var(--accent)" />
|
||||
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="var(--accent)" opacity="0.7" />
|
||||
<rect x="18" y="6" width="3" height="16" rx="1" fill="var(--accent)" opacity="0.45" />
|
||||
</svg>
|
||||
<span>LogUserRequest <small>Lite</small></span>
|
||||
</div>
|
||||
<h2>🔐 Вход</h2>
|
||||
<div class="error" id="errorMsg"></div>
|
||||
<form method="post" action="/lite/logrequest/login">
|
||||
<input type="password" name="password" placeholder="Пароль" autocomplete="off" autofocus>
|
||||
<button type="submit">Войти</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
const THEME_KEY = 'loguser-theme';
|
||||
function setTheme(dark) {
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
try { localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light'); } catch(e) {}
|
||||
}
|
||||
const saved = localStorage.getItem(THEME_KEY);
|
||||
setTheme(saved === 'dark' || (saved === null && window.matchMedia('(prefers-color-scheme: dark)').matches));
|
||||
document.getElementById('themeBtn').addEventListener('click', () => {
|
||||
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
|
||||
});
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.altKey && (e.key === 't' || e.key === 'T')) {
|
||||
e.preventDefault();
|
||||
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
|
||||
}
|
||||
});
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const errorEl = document.getElementById('errorMsg');
|
||||
if (urlParams.has('error')) {
|
||||
const remaining = urlParams.get('remaining');
|
||||
errorEl.innerText = remaining ? `Неверный пароль. Осталось попыток: ${remaining}` : 'Неверный пароль';
|
||||
errorEl.classList.add('show');
|
||||
}
|
||||
if (urlParams.has('blocked')) {
|
||||
errorEl.innerText = 'Доступ заблокирован на сутки';
|
||||
errorEl.classList.add('show');
|
||||
}
|
||||
|
||||
// Предотвращаем возврат на auth через кнопку "назад"
|
||||
(function() {
|
||||
if (window.history && window.history.replaceState) {
|
||||
const url = new URL(window.location);
|
||||
if (!url.searchParams.has('error') && !url.searchParams.has('blocked')) {
|
||||
window.history.replaceState(null, '', '/lite/logrequest');
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pageshow', function(event) {
|
||||
if (event.persisted) {
|
||||
fetch('/lite/logrequest/api?skip=0&take=1', {
|
||||
credentials: 'include',
|
||||
cache: 'no-cache'
|
||||
}).then(r => {
|
||||
if (r.ok) window.location.replace('/lite/logrequest');
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,487 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LogUserRequest Lite</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root { --font-body: 'Inter', system-ui, sans-serif; --font-mono: 'JetBrains Mono', monospace; --text-xs: 0.75rem; --text-sm: 0.875rem; --text-base: 1rem; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 20px; --sp-6: 24px; --r-md: 8px; --r-lg: 12px; --r-xl: 16px; }
|
||||
[data-theme="dark"] { --bg: #0c0e12; --surface: #13161d; --surface-2: #1a1e28; --surface-3: #222733; --border: #2a3040; --text: #e2e6ef; --text-muted: #7c849a; --text-faint: #4d5568; --accent: #3ec9d1; --accent-dim: rgba(62, 201, 209, 0.12); --ok: #4ade80; --err: #f87171; --shadow-sm: 0 1px 2px rgba(0,0,0,0.3); --modal-overlay: rgba(0,0,0,0.7); --chart-grid: #2a3040; }
|
||||
[data-theme="light"] { --bg: #f4f5f8; --surface: #ffffff; --surface-2: #f0f1f5; --surface-3: #e8eaf0; --border: #d4d8e3; --text: #1a1e28; --text-muted: #6b7280; --text-faint: #9ca3af; --accent: #0d9499; --accent-dim: rgba(13, 148, 153, 0.1); --ok: #16a34a; --err: #dc2626; --shadow-sm: 0 1px 2px rgba(0,0,0,0.06); --modal-overlay: rgba(0,0,0,0.5); --chart-grid: #d4d8e3; }
|
||||
html, body { height: 100%; }
|
||||
body { font-family: var(--font-body); font-size: var(--text-base); background: var(--bg); color: var(--text); line-height: 1.5; -webkit-font-smoothing: antialiased; padding: 20px; }
|
||||
.container { max-width: 1400px; margin: 0 auto; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.logo { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 1.2rem; color: var(--text); }
|
||||
.logo svg { width: 28px; height: 28px; }
|
||||
.logo small { font-weight: 400; color: var(--text-muted); margin-left: 4px; }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
.btn { font: inherit; font-size: var(--text-xs); font-weight: 600; padding: 8px 16px; border-radius: var(--r-md); border: 1px solid var(--accent); background: var(--accent-dim); color: var(--accent); cursor: pointer; transition: all 0.15s; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.btn:hover { background: rgba(62, 201, 209, 0.22); transform: translateY(-1px); }
|
||||
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.btn-primary:hover { background: var(--accent); opacity: 0.85; }
|
||||
.btn-premium { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); margin-left: 8px; }
|
||||
.btn-premium:hover { background: rgba(62, 201, 209, 0.22); }
|
||||
.btn-premium svg { width: 16px; height: 16px; }
|
||||
.btn-ghost { border-color: var(--border); background: var(--surface-2); color: var(--text-muted); }
|
||||
.btn-ghost:hover { color: var(--text); background: var(--surface-3); }
|
||||
.btn-icon { width: 34px; height: 34px; padding: 0; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface-2); color: var(--text-muted); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.btn-icon:hover { color: var(--text); background: var(--surface-3); }
|
||||
.btn-icon svg { width: 16px; height: 16px; }
|
||||
.filter-bar { display: flex; gap: 10px; margin-bottom: 24px; flex-wrap: wrap; }
|
||||
.filter-bar input { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-md); padding: 10px 16px; color: var(--text); font-size: 0.9rem; flex: 1; min-width: 200px; }
|
||||
.filter-bar input:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
.stats-block { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-xl); padding: 20px; margin-bottom: 24px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||||
.stat-card { background: var(--surface-2); border-radius: var(--r-lg); padding: 16px; text-align: center; border: 1px solid rgba(255,255,255,0.06); }
|
||||
.stat-value { font-size: 2rem; font-weight: 700; color: var(--accent); line-height: 1.2; }
|
||||
.stat-label { font-size: var(--text-xs); color: var(--text-muted); margin-top: 6px; }
|
||||
.top-section { margin-bottom: 20px; }
|
||||
.top-section h3 { font-size: 0.9rem; font-weight: 600; margin-bottom: 12px; color: var(--text-muted); }
|
||||
.scroll-grid { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.grid-item { background: var(--surface-2); border: 1px solid rgba(255,255,255,0.06); border-radius: var(--r-lg); padding: 8px 14px; min-width: 100px; display: inline-flex; align-items: center; gap: 8px; }
|
||||
.grid-rank { font-size: var(--text-xs); font-weight: 700; color: var(--text-faint); background: var(--surface-3); width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; }
|
||||
.grid-name { font-size: var(--text-sm); font-weight: 500; }
|
||||
.grid-count { font-size: var(--text-xs); color: var(--text-muted); background: var(--surface-3); padding: 2px 8px; border-radius: 6px; }
|
||||
.requests-list { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-xl); overflow: hidden; }
|
||||
.request-card { background: var(--surface-2); border-bottom: 1px solid var(--border); padding: 16px 20px; }
|
||||
.request-card:last-child { border-bottom: none; }
|
||||
.request-header { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 8px; font-size: 0.8rem; }
|
||||
.request-time { color: var(--text-faint); }
|
||||
.request-uid { color: var(--accent); font-weight: 600; }
|
||||
.request-ip { color: var(--text-muted); }
|
||||
.request-uri { font-family: var(--font-mono); font-size: 0.75rem; word-break: break-all; color: var(--text-muted); margin-top: 6px; }
|
||||
.loading, .empty { text-align: center; padding: 40px; color: var(--text-faint); }
|
||||
|
||||
/* Модальное окно */
|
||||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: var(--modal-overlay); display: flex; align-items: flex-start; justify-content: center; z-index: 1000; padding: 20px; padding-top: 60px; backdrop-filter: blur(4px); overflow-y: auto; }
|
||||
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-xl); max-width: 950px; width: 100%; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5); animation: modalSlide 0.2s ease; max-height: calc(100vh - 80px); overflow-y: auto; }
|
||||
@keyframes modalSlide { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--surface); z-index: 5; }
|
||||
.modal-header h2 { font-size: 1.25rem; font-weight: 700; display: flex; align-items: center; gap: 8px; }
|
||||
.modal-header h2 svg { width: 24px; height: 24px; color: var(--accent); }
|
||||
.modal-close { width: 32px; height: 32px; border-radius: var(--r-md); border: 1px solid var(--border); background: var(--surface-2); color: var(--text-muted); cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; }
|
||||
.modal-close:hover { color: var(--text); background: var(--surface-3); }
|
||||
.modal-body { padding: 24px; }
|
||||
|
||||
/* График в модалке */
|
||||
.lite-chart-block { background: var(--surface-2); border-radius: var(--r-lg); padding: 20px; margin-bottom: 32px; }
|
||||
.lite-chart-header { margin-bottom: 16px; }
|
||||
.lite-chart-header h3 { font-size: 1rem; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
|
||||
.lite-chart-header h3 svg { width: 20px; height: 20px; }
|
||||
.lite-chart-container { position: relative; height: 200px; }
|
||||
.lite-scroll-hint { display: flex; justify-content: center; margin-top: 12px; }
|
||||
.lite-scroll-arrow { color: var(--text-faint); font-size: var(--text-xs); display: flex; flex-direction: column; align-items: center; gap: 4px; opacity: 0.7; }
|
||||
.lite-scroll-arrow svg { width: 20px; height: 20px; animation: bounce 2s infinite; }
|
||||
@keyframes bounce { 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } 40% { transform: translateY(6px); } 60% { transform: translateY(3px); } }
|
||||
|
||||
.premium-section { margin-top: 8px; }
|
||||
.premium-header { display: flex; align-items: center; gap: 8px; margin-bottom: 24px; }
|
||||
.premium-header h3 { font-size: 1.1rem; font-weight: 700; color: var(--accent); display: flex; align-items: center; gap: 8px; }
|
||||
.premium-header svg { width: 24px; height: 24px; }
|
||||
|
||||
.comparison-table { width: 100%; border-collapse: collapse; margin: 0 0 24px; font-size: var(--text-sm); }
|
||||
.comparison-table th { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border); color: var(--text-muted); font-weight: 500; font-size: var(--text-xs); }
|
||||
.comparison-table td { padding: 8px; border-bottom: 1px solid var(--border); text-align: left; }
|
||||
.comparison-table tr:last-child td { border-bottom: none; }
|
||||
.comparison-table .feature-col { font-weight: 500; color: var(--text); }
|
||||
.comparison-table .lite-col { color: var(--text-muted); }
|
||||
.comparison-table .premium-col { color: var(--ok); }
|
||||
.comparison-table .both-col { color: var(--ok); }
|
||||
|
||||
.video-container { position: relative; padding-bottom: 56.25%; height: 0; margin: 24px 0; border-radius: var(--r-lg); overflow: hidden; border: 1px solid var(--border); }
|
||||
.video-container iframe { position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: 0; }
|
||||
|
||||
.telegram-section { background: var(--accent-dim); border-radius: var(--r-lg); padding: 24px; text-align: center; border: 1px solid var(--accent); }
|
||||
.telegram-section h3 { color: var(--accent); margin-bottom: 8px; }
|
||||
.telegram-section p { color: var(--text-muted); margin-bottom: 20px; }
|
||||
.telegram-link { display: inline-flex; align-items: center; gap: 8px; background: var(--accent); color: #fff; padding: 12px 24px; border-radius: var(--r-lg); text-decoration: none; font-weight: 600; transition: all 0.15s; }
|
||||
.telegram-link:hover { opacity: 0.85; transform: translateY(-2px); }
|
||||
.telegram-link svg { width: 20px; height: 20px; fill: #fff; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true">
|
||||
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5" fill="none" />
|
||||
<rect x="7" y="16" width="3" height="6" rx="1" fill="var(--accent)" />
|
||||
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="var(--accent)" opacity="0.7" />
|
||||
<rect x="18" y="6" width="3" height="16" rx="1" fill="var(--accent)" opacity="0.45" />
|
||||
</svg>
|
||||
<span>LogUserRequest Lite</span>
|
||||
</div>
|
||||
<button class="btn btn-premium" id="premiumBtn">
|
||||
|
||||
Premium
|
||||
</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-icon" id="themeBtn" title="Сменить тему (Alt+T)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-ghost" id="logoutBtn">Выйти</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<input type="text" id="uidInput" placeholder="UID пользователя (оставьте пустым для всех)">
|
||||
<button class="btn btn-primary" onclick="applyFilter()">Показать</button>
|
||||
<button class="btn btn-ghost" onclick="resetFilter()">Сбросить</button>
|
||||
</div>
|
||||
|
||||
<div class="stats-block" id="statsBlock" style="display:none;">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><div class="stat-value" id="statToday">0</div><div class="stat-label">Запросов сегодня</div></div>
|
||||
<div class="stat-card"><div class="stat-value" id="statMonth">0</div><div class="stat-label">За месяц</div></div>
|
||||
<div class="stat-card"><div class="stat-value" id="statDevices">0</div><div class="stat-label">Устройств</div></div>
|
||||
<div class="stat-card"><div class="stat-value" id="statIps">0</div><div class="stat-label">IP адресов</div></div>
|
||||
</div>
|
||||
<div class="top-section">
|
||||
<h3>👥 Топ пользователей</h3>
|
||||
<div class="scroll-grid" id="topUsers"></div>
|
||||
</div>
|
||||
<div class="top-section">
|
||||
<h3>⚡ Топ балансиров</h3>
|
||||
<div class="scroll-grid" id="topBalancers"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="requests-list" id="requestsList">
|
||||
<div class="loading">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно Premium -->
|
||||
<div class="modal-overlay hidden" id="premiumModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>
|
||||
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true">
|
||||
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5" fill="none" />
|
||||
<rect x="7" y="16" width="3" height="6" rx="1" fill="currentColor" />
|
||||
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="currentColor" opacity="0.7" />
|
||||
<rect x="18" y="6" width="3" height="16" rx="1" fill="currentColor" opacity="0.45" />
|
||||
</svg>
|
||||
LogUserRequest Lite
|
||||
</h2>
|
||||
<button class="modal-close" id="closeModalBtn">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Блок Lite-версии с графиком -->
|
||||
<div class="lite-chart-block">
|
||||
<div class="lite-chart-header">
|
||||
<h3>
|
||||
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true" width="20" height="20">
|
||||
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5" fill="none" />
|
||||
<rect x="7" y="16" width="3" height="6" rx="1" fill="currentColor" />
|
||||
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="currentColor" opacity="0.7" />
|
||||
<rect x="18" y="6" width="3" height="16" rx="1" fill="currentColor" opacity="0.45" />
|
||||
</svg>
|
||||
Активность за 3 дня
|
||||
</h3>
|
||||
</div>
|
||||
<div class="lite-chart-container">
|
||||
<canvas id="premiumChart"></canvas>
|
||||
</div>
|
||||
<div class="lite-scroll-hint">
|
||||
<span class="lite-scroll-arrow">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 5v14M19 12l-7 7-7-7"/>
|
||||
</svg>
|
||||
Прокрутите чтобы увидеть Premium-возможности
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Разделитель / пустое пространство -->
|
||||
<div style="height: 400px;"></div>
|
||||
|
||||
<!-- Premium секция -->
|
||||
<div class="premium-section">
|
||||
<div class="premium-header">
|
||||
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true">
|
||||
<rect x="2" y="2" width="24" height="24" rx="6" stroke="var(--accent)" stroke-width="1.5" fill="none" />
|
||||
<rect x="7" y="16" width="3" height="6" rx="1" fill="var(--accent)" />
|
||||
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="var(--accent)" opacity="0.7" />
|
||||
<rect x="18" y="6" width="3" height="16" rx="1" fill="var(--accent)" opacity="0.45" />
|
||||
</svg>
|
||||
<h3>LogUserRequest Premium</h3>
|
||||
</div>
|
||||
|
||||
<table class="comparison-table">
|
||||
<thead>
|
||||
<tr><th>Функция</th><th>Lite-версия</th><th>Premium-версия</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td class="feature-col">Логирование запросов</td><td class="both-col">✅ Все запросы</td><td class="both-col">✅ Все запросы</td></tr>
|
||||
<tr><td class="feature-col">Фильтрация служебных путей</td><td class="both-col">✅ Хардкод-список</td><td class="premium-col">✅ Гибкий UI + regex + IP</td></tr>
|
||||
<tr><td class="feature-col">Авторизация</td><td class="both-col">✅ Сессии + rate-limit</td><td class="both-col">✅ Сессии + rate-limit</td></tr>
|
||||
<tr><td class="feature-col">База данных</td><td class="both-col">✅ SQLite</td><td class="premium-col">✅ SQLite с индексами</td></tr>
|
||||
<tr><td class="feature-col">Очистка старых записей</td><td class="both-col">✅ По дням (logDay)</td><td class="premium-col">✅ По дням + по размеру БД</td></tr>
|
||||
<tr><td class="feature-col">Статистика</td><td class="both-col">✅ Топы пользователей и балансиров</td><td class="premium-col">✅ Топы + графики + модальные окна</td></tr>
|
||||
<tr><td class="feature-col">Тёмная/светлая тема</td><td class="both-col">✅ Есть</td><td class="both-col">✅ Есть</td></tr>
|
||||
<tr><td class="feature-col">Мобильная адаптация</td><td class="both-col">✅ Есть</td><td class="both-col">✅ Есть</td></tr>
|
||||
<tr><td class="feature-col">Поиск по UID</td><td class="both-col">✅ Поле ввода</td><td class="premium-col">✅ Поле ввода + модальное окно</td></tr>
|
||||
<tr><td class="feature-col">Дашборд</td><td class="lite-col">❌ Простой список</td><td class="premium-col">✅ Полноценный SPA с графиками</td></tr>
|
||||
<tr><td class="feature-col">Дедупликация перебора балансиров</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ First + Last (окно 30 сек)</td></tr>
|
||||
<tr><td class="feature-col">Фильтр по дате</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ На дашборде</td></tr>
|
||||
<tr><td class="feature-col">Активность в реальном времени</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ Live-обновления</td></tr>
|
||||
<tr><td class="feature-col">Экспорт данных</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ CSV/JSON (ZIP)</td></tr>
|
||||
<tr><td class="feature-col">Массовое удаление</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ С гибкими фильтрами</td></tr>
|
||||
<tr><td class="feature-col">Архивация</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ По дате + по размеру БД</td></tr>
|
||||
<tr><td class="feature-col">Telegram-уведомления</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ Мониторинг + архив + ошибки</td></tr>
|
||||
<tr><td class="feature-col">Health-check</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ API для мониторинга</td></tr>
|
||||
<tr><td class="feature-col">Настройка фильтров через UI</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ Модальное окно + авто-сохранение</td></tr>
|
||||
<tr><td class="feature-col">Защита от ReDoS</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ Кэширование regex + таймаут</td></tr>
|
||||
<tr><td class="feature-col">Очередь с защитой от переполнения</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ До 50000 записей</td></tr>
|
||||
<tr><td class="feature-col">Потребление памяти</td><td class="both-col">~20-30 МБ</td><td class="both-col">~50-70 МБ</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Видео-обзор -->
|
||||
<div class="video-container">
|
||||
<iframe src="https://drive.google.com/file/d/1VCgOGJIVy4CVjYV1wL47t_5Vb5dbs-Gg/preview" allow="autoplay; fullscreen" allowfullscreen></iframe>
|
||||
</div>
|
||||
|
||||
<!-- Telegram -->
|
||||
<div class="telegram-section">
|
||||
<h3>💬 Заинтересовал Premium?</h3>
|
||||
<p>Напишите автору в Telegram — отвечу на вопросы и дам доступ</p>
|
||||
<a href="https://t.me/Viacheslav_Sh1" target="_blank" class="telegram-link">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.562 8.161c-.18 1.897-.962 6.502-1.359 8.627-.168.9-.5 1.201-.82 1.23-.697.064-1.226-.46-1.901-.903-1.056-.692-1.653-1.123-2.678-1.799-1.185-.781-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.015-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.479.329-.913.489-1.302.481-.428-.009-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.015 3.333-1.386 4.025-1.627 4.476-1.635.099-.002.321.023.465.141.145.118.185.276.204.388.018.112.013.23.01.356z"/>
|
||||
</svg>
|
||||
@Viacheslav_Sh1
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const THEME_KEY = 'loguser-theme';
|
||||
function setTheme(dark) {
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
try { localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light'); } catch(e) {}
|
||||
}
|
||||
const saved = localStorage.getItem(THEME_KEY);
|
||||
setTheme(saved === 'dark' || (saved === null && window.matchMedia('(prefers-color-scheme: dark)').matches));
|
||||
document.getElementById('themeBtn').addEventListener('click', () => {
|
||||
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
|
||||
});
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.altKey && (e.key === 't' || e.key === 'T')) {
|
||||
e.preventDefault();
|
||||
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
|
||||
}
|
||||
});
|
||||
|
||||
let currentUid = '';
|
||||
let skip = 0;
|
||||
const take = 50;
|
||||
let loading = false;
|
||||
let allLoaded = false;
|
||||
let premiumChart = null;
|
||||
|
||||
// Модальное окно
|
||||
const modal = document.getElementById('premiumModal');
|
||||
const premiumBtn = document.getElementById('premiumBtn');
|
||||
const closeBtn = document.getElementById('closeModalBtn');
|
||||
|
||||
function openModal() {
|
||||
modal.classList.remove('hidden');
|
||||
loadPremiumChart();
|
||||
}
|
||||
|
||||
premiumBtn.addEventListener('click', openModal);
|
||||
closeBtn.addEventListener('click', () => modal.classList.add('hidden'));
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.add('hidden'); });
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') modal.classList.add('hidden'); });
|
||||
|
||||
async function apiCall(path) {
|
||||
const res = await fetch('/lite/logrequest' + path, { credentials: 'include' });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
window.location.href = '/lite/logrequest/auth';
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
const json = await res.json();
|
||||
return json.success ? json.data : json;
|
||||
}
|
||||
|
||||
async function loadPremiumChart() {
|
||||
if (premiumChart) return;
|
||||
|
||||
try {
|
||||
const result = await apiCall('/premium-chart?days=3');
|
||||
|
||||
const ctx = document.getElementById('premiumChart').getContext('2d');
|
||||
const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#3ec9d1';
|
||||
const gridColor = getComputedStyle(document.documentElement).getPropertyValue('--chart-grid').trim() || '#2a3040';
|
||||
|
||||
let labels, values;
|
||||
|
||||
if (result && result.data && result.data.length > 0) {
|
||||
labels = result.data.map(d => new Date(d.date).toLocaleDateString('ru', { day: 'numeric', month: 'short' }));
|
||||
values = result.data.map(d => d.count);
|
||||
} else {
|
||||
labels = ['Нет данных'];
|
||||
values = [0];
|
||||
}
|
||||
|
||||
premiumChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Запросы',
|
||||
data: values,
|
||||
borderColor: accentColor,
|
||||
backgroundColor: accentColor + '20',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointBackgroundColor: accentColor
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: { beginAtZero: true, grid: { color: gridColor } },
|
||||
x: { grid: { display: false } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch(e) {
|
||||
console.error('Chart error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const url = currentUid ? `/stats?uid=${encodeURIComponent(currentUid)}` : '/stats';
|
||||
const stats = await apiCall(url);
|
||||
if (!stats) return;
|
||||
|
||||
document.getElementById('statsBlock').style.display = 'block';
|
||||
document.getElementById('statToday').innerText = stats.today || 0;
|
||||
document.getElementById('statMonth').innerText = stats.month || 0;
|
||||
document.getElementById('statDevices').innerText = stats.uniqueUserAgent || 0;
|
||||
document.getElementById('statIps').innerText = stats.uniqueIp || 0;
|
||||
|
||||
const topUsers = document.getElementById('topUsers');
|
||||
const users = stats.topUsers || [];
|
||||
topUsers.innerHTML = users.map((u, idx) => `
|
||||
<div class="grid-item">
|
||||
<span class="grid-rank">${idx + 1}</span>
|
||||
<span class="grid-name">${escapeHtml(u.name)}</span>
|
||||
<span class="grid-count">${u.count}</span>
|
||||
</div>
|
||||
`).join('') || '<div class="empty">Нет данных</div>';
|
||||
|
||||
const topBalancers = document.getElementById('topBalancers');
|
||||
const balancers = stats.topBalancers || [];
|
||||
topBalancers.innerHTML = balancers.map((b, idx) => `
|
||||
<div class="grid-item">
|
||||
<span class="grid-rank">${idx + 1}</span>
|
||||
<span class="grid-name">${escapeHtml(b.name)}</span>
|
||||
<span class="grid-count">${b.count}</span>
|
||||
</div>
|
||||
`).join('') || '<div class="empty">Нет данных</div>';
|
||||
} catch(e) {
|
||||
console.error('Stats error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRequests(reset = true) {
|
||||
if (reset) { skip = 0; allLoaded = false; document.getElementById('requestsList').innerHTML = ''; }
|
||||
if (loading || allLoaded) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
let url = `/api?skip=${skip}&take=${take}`;
|
||||
if (currentUid) url += `&uid=${encodeURIComponent(currentUid)}`;
|
||||
|
||||
const data = await apiCall(url);
|
||||
|
||||
if (!data || (Array.isArray(data) && data.length === 0)) {
|
||||
allLoaded = true;
|
||||
if (skip === 0) document.getElementById('requestsList').innerHTML = '<div class="empty">Нет записей</div>';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (reset) document.getElementById('requestsList').innerHTML = '';
|
||||
|
||||
data.forEach(r => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'request-card';
|
||||
card.innerHTML = `
|
||||
<div class="request-header">
|
||||
<span class="request-time">${new Date(r.time).toLocaleString()}</span>
|
||||
<span class="request-uid">${escapeHtml(r.userUid || r.uid || 'unknown')}</span>
|
||||
<span class="request-ip">${escapeHtml(r.ip || 'unknown')}</span>
|
||||
</div>
|
||||
<div class="request-uri">${escapeHtml(r.uri || '')}</div>
|
||||
`;
|
||||
document.getElementById('requestsList').appendChild(card);
|
||||
});
|
||||
|
||||
skip += data.length;
|
||||
if (data.length < take) allLoaded = true;
|
||||
loading = false;
|
||||
} catch(e) {
|
||||
console.error('Load error:', e);
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/[&<>]/g, m => ({ '&': '&', '<': '<', '>': '>' }[m] || m));
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
currentUid = document.getElementById('uidInput').value.trim();
|
||||
loadStats();
|
||||
loadRequests(true);
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
document.getElementById('uidInput').value = '';
|
||||
currentUid = '';
|
||||
loadStats();
|
||||
loadRequests(true);
|
||||
}
|
||||
|
||||
document.getElementById('logoutBtn').addEventListener('click', () => {
|
||||
window.location.href = '/lite/logrequest/logout';
|
||||
});
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 500) {
|
||||
if (!loading && !allLoaded) loadRequests(false);
|
||||
}
|
||||
});
|
||||
|
||||
loadStats();
|
||||
loadRequests(true);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enable": true,
|
||||
"version": 1,
|
||||
"namespace": "LogUserRequest",
|
||||
"logDay": 90
|
||||
}
|
||||
Reference in New Issue
Block a user