Исправление: том ./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
+88
View File
@@ -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);
}
+24
View File
@@ -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);
}
}
+375
View File
@@ -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; }
}
+336
View File
@@ -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.
+110
View File
@@ -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>
+487
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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>
+6
View File
@@ -0,0 +1,6 @@
{
"enable": true,
"version": 1,
"namespace": "LogUserRequest",
"logDay": 90
}