FEAT: Рилтайм обновление настроек на сайте

1. Настройки на сайте обновляются без кнопки отправить
2. Добавлена кнопка для начисления монет через сайт(пока заглушка)
This commit is contained in:
_sega 2026-01-28 13:05:48 +03:00
parent 0ba6c842b2
commit a8b26773e8
6 changed files with 737 additions and 231 deletions

View File

@ -5,6 +5,16 @@
box-sizing: border-box;
}
.panel { padding: 16px; margin: 16px 0; border-radius: 12px; background: rgba(255,255,255,0.04); }
.coins-row { display:flex; gap:12px; align-items:center; }
.coins-input { width: 140px; }
.coins-btn { padding: 8px 14px; cursor:pointer; }
.status { margin-top: 10px; opacity: 0.85; font-size: 14px; }
.radio-row { display:flex; gap:10px; align-items:center; margin: 6px 0; }
.login-root { max-width: 360px; margin: 60px auto; padding: 18px; border-radius: 12px; background: rgba(255,255,255,0.04); }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);

View File

@ -0,0 +1,191 @@
using System;
using System.IO;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;
public static class AuthManager
{
private const string CookieName = "fishka_auth";
private const string TokenSeparator = ".";
private const string AppSalt = "FISHKA_WEB_ADMIN_V1"; // поменяй на своё
// Берем пароль из файла (если есть), иначе fallback из инспектора
public static string LoadPasswordOrFallback(string fallback, string fileName)
{
try
{
string path = Path.Combine(Application.persistentDataPath, fileName);
if (File.Exists(path))
{
var s = File.ReadAllText(path, Encoding.UTF8).Trim();
if (!string.IsNullOrEmpty(s)) return s;
}
}
catch (Exception e)
{
Debug.LogWarning($"Password file read failed: {e.Message}");
}
return fallback ?? "";
}
public static bool VerifyPassword(string entered, string actual)
{
if (entered == null) entered = "";
if (actual == null) actual = "";
return ConstantTimeEquals(entered, actual);
}
public static bool IsAuthorized(HttpListenerRequest request, string password)
{
try
{
var cookie = request.Cookies?[CookieName];
if (cookie == null || string.IsNullOrEmpty(cookie.Value)) return false;
return ValidateToken(cookie.Value, password);
}
catch
{
return false;
}
}
public static void SetAuthCookie(HttpListenerResponse response, string token, int daysValid)
{
// Cookie через header руками, чтобы не упираться в SameSite API разных .NET в Unity
int maxAge = Mathf.Clamp(daysValid, 1, 3650) * 24 * 60 * 60;
string header = $"{CookieName}={token}; Path=/; Max-Age={maxAge}; HttpOnly; SameSite=Lax";
response.Headers.Add("Set-Cookie", header);
}
public static string GenerateToken(string password, int daysValid)
{
long exp = DateTimeOffset.UtcNow.AddDays(daysValid).ToUnixTimeSeconds();
string nonce = Guid.NewGuid().ToString("N");
string payload = $"{exp}:{nonce}";
byte[] sig = HmacSha256(GetSecretKey(password), payload);
return Base64UrlEncode(Encoding.UTF8.GetBytes(payload)) + TokenSeparator + Base64UrlEncode(sig);
}
private static bool ValidateToken(string token, string password)
{
if (string.IsNullOrEmpty(token)) return false;
var parts = token.Split(new[] { TokenSeparator }, StringSplitOptions.None);
if (parts.Length != 2) return false;
byte[] payloadBytes, sigBytes;
try
{
payloadBytes = Base64UrlDecode(parts[0]);
sigBytes = Base64UrlDecode(parts[1]);
}
catch { return false; }
string payload = Encoding.UTF8.GetString(payloadBytes);
byte[] expected = HmacSha256(GetSecretKey(password), payload);
if (!FixedTimeEquals(sigBytes, expected)) return false;
// exp
var idx = payload.IndexOf(':');
if (idx <= 0) return false;
if (!long.TryParse(payload.Substring(0, idx), out long exp)) return false;
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (now > exp) return false;
return true;
}
private static byte[] GetSecretKey(string password)
{
// Если поменяешь пароль — старые куки станут невалидными (это плюс).
string s = $"{AppSalt}:{password}";
return Sha256(Encoding.UTF8.GetBytes(s));
}
private static byte[] Sha256(byte[] data)
{
using var sha = SHA256.Create();
return sha.ComputeHash(data);
}
private static byte[] HmacSha256(byte[] key, string data)
{
using var h = new HMACSHA256(key);
return h.ComputeHash(Encoding.UTF8.GetBytes(data));
}
// Достаём пароль из body: либо form-url-encoded (password=...), либо json {"password":"..."}
public static string ExtractPassword(string body, string contentType)
{
if (string.IsNullOrEmpty(body)) return "";
contentType = (contentType ?? "").ToLower();
if (contentType.Contains("application/json"))
{
// очень простой парсер под {"password":"..."}
// если хочешь — можно заменить на DTO+JsonUtility
const string key = "\"password\"";
int i = body.IndexOf(key, StringComparison.OrdinalIgnoreCase);
if (i < 0) return "";
int colon = body.IndexOf(':', i);
if (colon < 0) return "";
int q1 = body.IndexOf('"', colon + 1);
if (q1 < 0) return "";
int q2 = body.IndexOf('"', q1 + 1);
if (q2 < 0) return "";
return body.Substring(q1 + 1, q2 - q1 - 1);
}
// form
try
{
var nvc = System.Web.HttpUtility.ParseQueryString(body, Encoding.UTF8);
return nvc["password"] ?? "";
}
catch
{
return "";
}
}
private static string Base64UrlEncode(byte[] data)
{
return Convert.ToBase64String(data)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static byte[] Base64UrlDecode(string s)
{
s = s.Replace('-', '+').Replace('_', '/');
switch (s.Length % 4)
{
case 2: s += "=="; break;
case 3: s += "="; break;
}
return Convert.FromBase64String(s);
}
private static bool ConstantTimeEquals(string a, string b)
{
var ba = Encoding.UTF8.GetBytes(a);
var bb = Encoding.UTF8.GetBytes(b);
return FixedTimeEquals(ba, bb);
}
private static bool FixedTimeEquals(byte[] a, byte[] b)
{
if (a == null || b == null) return false;
if (a.Length != b.Length) return false;
int diff = 0;
for (int i = 0; i < a.Length; i++)
diff |= a[i] ^ b[i];
return diff == 0;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: be4e78088aed1e049a925227fdc3b2da
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,48 +1,208 @@
using System.Collections;
using System.Collections.Generic;
using System.Collections.Generic;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;
public class HtmlBuilder : MonoBehaviour
public class HtmlBuilder
{
public static string GetSettingsPage(List<Setting> settings)
{
return Decorate(GetSettingsString(settings));
return Decorate(GetSettingsString(settings), title: "Настройки Фишки");
}
public static string GetLoginPage()
{
string content = @"
<div class='login-root'>
<h1>Вход</h1>
<div class='form-group'>
<label for='pass'>Пароль</label>
<input id='pass' type='password' autocomplete='current-password' />
</div>
<button id='loginBtn' type='button'>Войти</button>
<p id='loginStatus' style='margin-top:12px;'></p>
</div>
<script>
(function(){
const pass = document.getElementById('pass');
const btn = document.getElementById('loginBtn');
const st = document.getElementById('loginStatus');
async function doLogin(){
st.textContent = '';
try{
const r = await fetch('/api/login', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ password: pass.value || '' })
});
const t = await r.text();
if(!r.ok){
st.textContent = 'Ошибка входа';
try{
const j = JSON.parse(t);
if(j && j.message) st.textContent = j.message;
}catch(e){}
return;
}
window.location.href = '/';
}catch(e){
st.textContent = 'Сервер недоступен';
}
}
btn.addEventListener('click', doLogin);
pass.addEventListener('keydown', (e)=>{ if(e.key === 'Enter') doLogin(); });
})();
</script>
";
return Decorate(content, title: "Вход");
}
public static string GetSettingsString(List<Setting> settings)
{
StringBuilder sb = new StringBuilder();
var sb = new StringBuilder();
sb.AppendLine("<h1>Настройки фишки</h1>");
sb.AppendLine("<form action=\"/upd\" method=\"POST\" class=\"form-root\">");
// блок монет
sb.AppendLine(@"
<div class='panel'>
<h2>Монеты</h2>
<div class='coins-row'>
<input id='coinsAmount' type='number' value='10' min='1' step='1' class='coins-input'/>
<button id='addCoinsBtn' type='button' class='coins-btn'>Добавить монеты</button>
</div>
<div id='coinsStatus' class='status'></div>
</div>
");
sb.AppendLine("<div class='panel'>");
sb.AppendLine("<h2>Параметры</h2>");
foreach (Setting setting in settings)
{
string view = string.Empty;
if(setting.Type== SettingType.Bool)
string view = setting.Type switch
{
view = GetBoolView(setting);
}
else if(setting.Type == SettingType.Float)
{
view = GetFloatView(setting);
}
else if(setting.Type == SettingType.String)
{
view = GetStringView(setting);
}
else
{
view = $"<p>{setting.Alias}: {setting.IntValue.ToString()}</p>";
}
SettingType.Bool => GetBoolView(setting),
SettingType.Float => GetFloatView(setting),
SettingType.String => GetStringView(setting),
SettingType.Int => GetIntView(setting),
_ => $"<p>{Escape(setting.Alias)}: (unknown)</p>"
};
sb.AppendLine(view);
}
sb.AppendLine("<input type=\"submit\">");
sb.AppendLine("</form>");
sb.AppendLine("</div>");
// JS для realtime отправки
sb.AppendLine(@"
<script>
(function(){
const status = (txt)=>{
const el = document.getElementById('globalStatus');
if(!el) return;
el.textContent = txt || '';
if(txt){ setTimeout(()=>{ el.textContent=''; }, 1200); }
};
const debounceMap = new Map();
function debounce(key, fn, ms){
if(debounceMap.has(key)) clearTimeout(debounceMap.get(key));
debounceMap.set(key, setTimeout(fn, ms));
}
async function sendSetting(id, value){
try{
const r = await fetch('/api/setting', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ id, value })
});
if(!r.ok){
status('Ошибка применения');
return;
}
status('Сохранено');
}catch(e){
status('Сервер недоступен');
}
}
// Все элементы настроек
document.querySelectorAll('[data-setting-id]').forEach(el=>{
const id = el.getAttribute('data-setting-id');
if(el.type === 'checkbox'){
el.addEventListener('change', ()=>{
sendSetting(id, el.checked ? 'on' : 'off');
});
return;
}
if(el.type === 'range'){
const valueSpanId = el.getAttribute('data-value-span');
const valueSpan = valueSpanId ? document.getElementById(valueSpanId) : null;
const apply = ()=>{
if(valueSpan) valueSpan.textContent = el.value;
// range шлём с небольшой задержкой, чтобы не заспамить
debounce('rng_'+id, ()=>sendSetting(id, el.value), 120);
};
el.addEventListener('input', apply);
el.addEventListener('change', apply);
return;
}
if(el.type === 'radio'){
el.addEventListener('change', ()=>{
if(el.checked) sendSetting(id, el.value);
});
return;
}
// number/text (int)
el.addEventListener('change', ()=>{
sendSetting(id, el.value);
});
});
// Монеты
const coinsAmount = document.getElementById('coinsAmount');
const addBtn = document.getElementById('addCoinsBtn');
const coinsStatus = document.getElementById('coinsStatus');
async function addCoins(){
coinsStatus.textContent = '';
const v = parseInt(coinsAmount.value || '0', 10);
if(!Number.isFinite(v) || v <= 0){
coinsStatus.textContent = 'Введи число > 0';
return;
}
try{
const r = await fetch('/api/coins', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ amount: v })
});
if(!r.ok){
coinsStatus.textContent = 'Ошибка запроса';
return;
}
coinsStatus.textContent = 'Ок';
setTimeout(()=>{ coinsStatus.textContent=''; }, 1200);
}catch(e){
coinsStatus.textContent = 'Сервер недоступен';
}
}
addBtn.addEventListener('click', addCoins);
})();
</script>
<div id='globalStatus' class='status'></div>
");
return sb.ToString();
}
@ -52,96 +212,99 @@ public class HtmlBuilder : MonoBehaviour
string name = setting.Id;
string id = name + "ID";
string isChecked = setting.BoolValue ? "checked" : "";
string res = string.Empty;
res += $"<div class=\"form-group\">";
res += $"<label for=\"{id}\">{setting.Alias}</label>";
string ischecked = setting.BoolValue ? "checked" : "";
res += $"<input type=\"checkbox\" name=\"{name}\" id=\"{id}\" {ischecked}>";
res += "</div>";
return res;
return $@"
<div class='form-group'>
<label for='{id}'>{Escape(setting.Alias)}</label>
<input type='checkbox' id='{id}' data-setting-id='{EscapeAttr(name)}' {isChecked}>
</div>";
}
private static string GetFloatView(Setting setting)
{
string name = setting.Id;
string id = name + "ID";
string spanId = id + "_VAL";
string res = string.Empty;
string min = setting.FloatMin.ToString(CultureInfo.InvariantCulture);
string max = setting.FloatMax.ToString(CultureInfo.InvariantCulture);
string value = setting.FloatValue.ToString(CultureInfo.InvariantCulture);
string step = setting.FloatStep.ToString(CultureInfo.InvariantCulture);
res += $"<div class=\"form-group\">";
res += $"<label for=\"{id}\">{setting.Alias}</label>";
res += $"<input id=\"{id}\" type=\"range\" min=\"{min}\" max=\"{max}\" value=\"{value}\" step=\"{step}\" name=\"{name}\">";
res += "</div>";
return res;
return $@"
<div class='form-group'>
<label for='{id}'>{Escape(setting.Alias)}: <span id='{spanId}'>{value}</span></label>
<input id='{id}' type='range' min='{min}' max='{max}' value='{value}' step='{step}'
data-setting-id='{EscapeAttr(name)}' data-value-span='{spanId}'>
</div>";
}
private static string GetStringView(Setting setting)
{
string res = string.Empty;
res += $"<div class=\"form-group\">";
res += $"<p>{setting.Alias}</p>";
var sb = new StringBuilder();
sb.AppendLine("<div class='form-group'>");
sb.AppendLine($"<p>{Escape(setting.Alias)}</p>");
for (int i = 0; i < setting.AllowedValues.Count; i++)
{
string stringValue = setting.AllowedValues[i];
string name = setting.Id;
string id = name+ "ID"+i;
string id = name + "ID" + i;
bool isChecked = setting.StringValue == stringValue;
string Checked = isChecked ? "checked" : "";
res += "<div>";
res += $"<label for=\"{id}\">{stringValue}</label>";
res += $"<input type=\"radio\" id=\"{id}\" name=\"{name}\" value=\"{stringValue}\" {Checked}>";
res += "</div>";
sb.AppendLine("<div class='radio-row'>");
sb.AppendLine($"<label for='{EscapeAttr(id)}'>{Escape(stringValue)}</label>");
sb.AppendLine(
$"<input type='radio' id='{EscapeAttr(id)}' " +
$"name='{EscapeAttr(name)}' value='{EscapeAttr(stringValue)}' {Checked} data-setting-id='{EscapeAttr(name)}'>"
);
sb.AppendLine("</div>");
}
res += "</div>";
return res;
sb.AppendLine("</div>");
return sb.ToString();
}
private static string GetIntView(Setting setting)
{
string name = setting.Id;
string id = name + "ID";
return string.Empty;
return $@"
<div class='form-group'>
<label for='{id}'>{Escape(setting.Alias)}</label>
<input id='{id}' type='number' value='{setting.IntValue}' step='1'
data-setting-id='{EscapeAttr(name)}'>
</div>";
}
private static string Decorate(string content)
private static string Decorate(string content, string title)
{
string res = "<!DOCTYPE html>"
+ "<html lang=\"ru\">"
+ "<head>"
+ "<meta charset=\"utf-8\">"
+ "<link rel=\"stylesheet\" href=\"style.css\">"
+ "<title>Настройки Фишки</title>"
+ "</head>"
+ "<body>";
string res = "<!DOCTYPE html>"
+ "<html lang='ru'>"
+ "<head>"
+ "<meta charset='utf-8'>"
+ "<meta name='viewport' content='width=device-width, initial-scale=1'>"
+ "<link rel='stylesheet' href='style.css'>"
+ $"<title>{Escape(title)}</title>"
+ "</head>"
+ "<body>";
res += content;
res += "</body>" +
"</html>";
res += "</body></html>";
return res;
}
// минимальная защита от мусора в html
private static string Escape(string s)
{
if (string.IsNullOrEmpty(s)) return "";
return s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");
}
private static string EscapeAttr(string s) => Escape(s).Replace("'", "&#39;");
}

View File

@ -6,40 +6,37 @@ using UnityEngine;
public static class SettingsApplier
{
public static (bool OK, string log) ApplySettings(Dictionary<string, string> keyValuePairs)
public static (bool OK, string log) ApplySettings(
Dictionary<string, string> keyValuePairs,
bool assumeMissingBoolOff
)
{
List<Setting> settings = SettingsManager.Container.Settings;
//тут надо еще взять все id из настроек и если какое то значение не пришло значит оно false
foreach (var kv in keyValuePairs)
{
var searchResult = GetSettingById(kv.Key, settings);
if (searchResult.found)
{//есть такая настройка в менеджере
InjectNewValueToSetting(kv.Value, searchResult.setting);
}
else
{//нет такой настройки в менеджере
}
}
foreach(string id in FalseBoolSettingIds(settings, keyValuePairs.Keys.ToList()))
// ВАЖНО:
// - true: старое поведение для submit формы — то чего нет в POST, считаем false.
// - false: новое поведение для realtime — ничего не трогаем кроме пришедшего id.
if (assumeMissingBoolOff)
{
InjectNewValueToSetting("off", GetSettingById(id, settings).setting);
foreach (string id in FalseBoolSettingIds(settings, keyValuePairs.Keys.ToList()))
{
InjectNewValueToSetting("off", GetSettingById(id, settings).setting);
}
}
return (OK: true, log: "");
}
private static (bool found, Setting setting) GetSettingById(string settingId, List<Setting> settings)
{
Setting setting = settings.Find(s => s.Id == settingId);
if (setting != null)
return (found: true, setting);
else
@ -48,74 +45,58 @@ public static class SettingsApplier
private static void InjectNewValueToSetting(string newValue, Setting setting)
{
if (setting == null) return;
if (setting.Type == SettingType.String)
{
if (setting.AllowedValues.Contains(newValue))
{
setting.StringValue = newValue;
}
else
{
Debug.Log($"{setting.Id}: Попытка установить недопустимое значение \"{newValue}\"");
}
Debug.Log($"{setting.Id}: недопустимое значение \"{newValue}\"");
}
else if (setting.Type == SettingType.Float)
{
float value=0;
try
if (!float.TryParse(newValue, NumberStyles.Float, CultureInfo.InvariantCulture, out float value))
{
value = float.Parse(newValue, CultureInfo.InvariantCulture);
}
catch (System.Exception e)
{
Debug.Log($"{setting.Id}: Ошибка парсинга значения \"{newValue}\". {e}");//тут парсится неправильно
Debug.Log($"{setting.Id}: ошибка парсинга float \"{newValue}\"");
return;
}
//если спарсилось, проверяем пределы
if(value <= setting.FloatMax && value >= setting.FloatMin)
{
if (value <= setting.FloatMax && value >= setting.FloatMin)
setting.FloatValue = value;
}
else
{//не подходит под интервал
Debug.Log($"{setting.Id}: Новое значение за пределами ограничителей\"{newValue}\".");
}
Debug.Log($"{setting.Id}: float вне диапазона \"{newValue}\"");
}
else if (setting.Type == SettingType.Bool)
{
bool value=false;
if(newValue == "on") { value = true; }
setting.BoolValue = value;
setting.BoolValue = (newValue == "on" || newValue == "true" || newValue == "1");
}
else if (setting.Type == SettingType.Int)
{
setting.IntValue = int.Parse(newValue);
if (int.TryParse(newValue, out int iv))
setting.IntValue = iv;
else
Debug.Log($"{setting.Id}: ошибка парсинга int \"{newValue}\"");
}
}
private static List<string> FalseBoolSettingIds(List<Setting> settings, List<string> entry)
{
List<string> ids = new();
var missing = new List<string>();
foreach (Setting setting in settings)
{
if (entry.Contains(setting.Id)) continue;
ids.Add(setting.Id);
missing.Add(setting.Id);
}
List<string> BoolSettings = new List<string>();
foreach (string id in ids)
var boolIds = new List<string>();
foreach (string id in missing)
{
var searchResult = GetSettingById(id, settings);
if (searchResult.found && searchResult.setting.Type == SettingType.Bool)
{
BoolSettings.Add(id);
}
boolIds.Add(id);
}
return BoolSettings;
}
return boolIds;
}
}

View File

@ -21,9 +21,15 @@ public class WebServer : MonoBehaviour
private static byte[] css;
[Header("Auth")]
[SerializeField] private string adminPasswordFallback = "1234"; // если файла нет
[SerializeField] private string passwordFileName = "admin_password.txt"; // лежит в persistentDataPath
private string AdminPassword => AuthManager.LoadPasswordOrFallback(adminPasswordFallback, passwordFileName);
async void Start()
{
SettingsManager.Initialize();//надо будет убирать
// SettingsManager.Initialize();//надо будет убирать
LoadCss();
await StartWebServerAsync();
@ -49,7 +55,7 @@ public class WebServer : MonoBehaviour
var context = await listener.GetContextAsync();
_ = Task.Run(() => ProcessRequestAsync(context));
}
catch (System.Exception e)
catch (Exception e)
{
Debug.LogError($"Ошибка: {e.Message}");
}
@ -63,24 +69,64 @@ public class WebServer : MonoBehaviour
string path = context.Request.Url.LocalPath;
string method = context.Request.HttpMethod;
Debug.Log($"Запрос: {method} {path}");
// Debug.Log($"Запрос: {method} {path}");
// Публичные маршруты (без авторизации)
if (method == "GET" && path == "/style.css")
{
await HandleCssRequestAsync(context);
return;
}
if (method == "GET" && path == "/login")
{
SendHtmlResponse(context, HtmlBuilder.GetLoginPage());
return;
}
if (method == "POST" && path == "/api/login")
{
await HandlePostLoginAsync(context);
return;
}
// Дальше всё — только с авторизацией
if (!AuthManager.IsAuthorized(context.Request, AdminPassword))
{
// красивый редирект на /login
Redirect(context, "/login");
return;
}
// Главная страница
if (method == "GET" && path == "/")
{
await HandleGetSettingsAsync(context);
return;
}
else if (method == "GET" && path == "/style.css")
// Рилтайм апдейт 1 настройки
if (method == "POST" && path == "/api/setting")
{
await HandleCssRequestAsync(context);
await HandlePostSingleSettingAsync(context);
return;
}
else if (method == "POST" && path == "/upd")
// Кнопка "Добавить монеты"
if (method == "POST" && path == "/api/coins")
{
await HandlePostCoinsAsync(context);
return;
}
// LEGACY (оставил, если вдруг где-то дергается)
if (method == "POST" && path == "/upd")
{
await HandlePostUpdateAsync(context);
return;
}
else
{
SendHtmlResponse(context, "<h1>404 Not Found</h1>", 404);
}
SendHtmlResponse(context, "<h1>404 Not Found</h1>", 404);
}
catch (Exception ex)
{
@ -95,35 +141,31 @@ public class WebServer : MonoBehaviour
{
try
{
Debug.Log("Starting SettingsToHtml...");
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var htmlTask = Task.Run(() =>
HtmlBuilder.GetSettingsPage(SettingsManager.Container.Settings));
//SettingsToHtml.GetSettingsString(SettingsManager.Container.Settings));
HtmlBuilder.GetSettingsPage(SettingsManager.Container.Settings)
);
if (htmlTask.Wait(TimeSpan.FromSeconds(5)))
{
string htmlContent = htmlTask.Result;
stopwatch.Stop();
Debug.Log($"GetSettingsString выполнился за {stopwatch.ElapsedMilliseconds}ms");
if (string.IsNullOrEmpty(htmlContent))
{
htmlContent = "<h1>Error: Method returned empty string</h1>";
}
SendHtmlResponse(context, htmlContent);
}
else
{
Debug.LogError("GetSettingsString TIMEOUT (5 seconds)!");
Debug.LogError("HTML generation TIMEOUT (5 seconds)!");
SendHtmlResponse(context, "<h1>Error: Settings generation timeout</h1>", 500);
}
}
catch (Exception ex)
{
Debug.LogError($"Error in GetSettingsString: {ex.Message}");
Debug.LogError($"Error in settings html: {ex.Message}");
SendHtmlResponse(context, $"<h1>Error: {ex.Message}</h1>", 500);
}
});
@ -131,7 +173,7 @@ public class WebServer : MonoBehaviour
async Task HandleCssRequestAsync(HttpListenerContext context)
{
context.Response.ContentType = "text/css";
context.Response.ContentType = "text/css; charset=utf-8";
context.Response.ContentLength64 = css.Length;
await context.Response.OutputStream.WriteAsync(css, 0, css.Length);
context.Response.OutputStream.Close();
@ -141,28 +183,149 @@ public class WebServer : MonoBehaviour
{
TextAsset cssAsset = Resources.Load<TextAsset>("style");
try
{
print(cssAsset.text);
}
catch (Exception)
{
throw;
}
if (cssAsset != null)
{
css= Encoding.UTF8.GetBytes(cssAsset.text);
css = Encoding.UTF8.GetBytes(cssAsset.text);
Debug.Log("CSS OK");
}
else
{
Debug.LogWarning("CSS NOT OK");
css = new byte[0];
}
css = Array.Empty<byte>();
}
}
// =========================
// AUTH
// =========================
async Task HandlePostLoginAsync(HttpListenerContext context)
{
try
{
string body = await ReadBodyAsStringAsync(context.Request);
// принимаем и form-url-encoded, и json
string pass = AuthManager.ExtractPassword(body, context.Request.ContentType);
if (string.IsNullOrEmpty(pass))
{
SendJson(context, 400, "{\"ok\":false,\"message\":\"Пароль пустой\"}");
return;
}
if (!AuthManager.VerifyPassword(pass, AdminPassword))
{
SendJson(context, 401, "{\"ok\":false,\"message\":\"Неверный пароль\"}");
return;
}
string token = AuthManager.GenerateToken(AdminPassword, daysValid: 30);
AuthManager.SetAuthCookie(context.Response, token, daysValid: 30);
SendJson(context, 200, "{\"ok\":true}");
}
catch (Exception ex)
{
Debug.LogError($"Login error: {ex.Message}");
SendJson(context, 500, "{\"ok\":false,\"message\":\"Server error\"}");
}
}
// =========================
// REALTIME SETTINGS
// =========================
[Serializable]
private class SettingUpdateDto
{
public string id;
public string value;
}
async Task HandlePostSingleSettingAsync(HttpListenerContext context)
{
try
{
string body = await ReadBodyAsStringAsync(context.Request);
// Ждем JSON: {"id":"SETTING_ID","value":"on/off/123/1.5/SomeString"}
var dto = JsonUtility.FromJson<SettingUpdateDto>(body);
if (dto == null || string.IsNullOrEmpty(dto.id))
{
SendJson(context, 400, "{\"ok\":false,\"message\":\"Bad payload\"}");
return;
}
var (ok, log) = SettingsApplier.ApplySettings(
new Dictionary<string, string> { { dto.id, dto.value ?? "" } },
assumeMissingBoolOff: false // важно! иначе вырубишь все остальные bool
);
if (!ok)
{
SendJson(context, 400, "{\"ok\":false,\"message\":\"Apply failed\"}");
return;
}
// применяем на главном потоке
UnityMainThreadDispatcher.Instance.Enqueue(() => SettingsManager.OnSettingsChanged?.Invoke());
UnityMainThreadDispatcher.Instance.Enqueue(() => GameManager.instance.AdminPanel
.GetComponent<SettingsScreen>().RefreshViews());
SendJson(context, 200, "{\"ok\":true}");
}
catch (Exception ex)
{
Debug.LogError($"POST /api/setting error: {ex.Message}\n{ex.StackTrace}");
SendJson(context, 500, "{\"ok\":false,\"message\":\"Server error\"}");
}
}
// =========================
// COINS HANDLER
// =========================
[Serializable]
private class CoinsDto
{
public int amount;
}
async Task HandlePostCoinsAsync(HttpListenerContext context)
{
try
{
string body = await ReadBodyAsStringAsync(context.Request);
var dto = JsonUtility.FromJson<CoinsDto>(body);
int amount = dto?.amount ?? 0;
if (amount <= 0)
{
SendJson(context, 400, "{\"ok\":false,\"message\":\"amount must be > 0\"}");
return;
}
// ЗАГОТОВКА: сюда ты впишешь реальную логику
UnityMainThreadDispatcher.Instance.Enqueue(() => OnAddCoins(amount));
SendJson(context, 200, "{\"ok\":true}");
}
catch (Exception ex)
{
Debug.LogError($"POST /api/coins error: {ex.Message}\n{ex.StackTrace}");
SendJson(context, 500, "{\"ok\":false,\"message\":\"Server error\"}");
}
}
private void OnAddCoins(int amount)
{
// TODO: ты сам впишешь функционал
Debug.Log($"[COINS] Add coins requested: {amount}");
}
// =========================
// LEGACY /upd (как было)
// =========================
async Task HandlePostUpdateAsync(HttpListenerContext context)
{
try
@ -176,42 +339,25 @@ public class WebServer : MonoBehaviour
{
string charset = contentType.Split("charset=")[1].Trim().Split(';')[0];
encoding = Encoding.GetEncoding(charset);
Debug.Log($"Определена кодировка из Content-Type: {charset}");
}
catch
{
encoding = Encoding.UTF8;
Debug.Log($"Не удалось определить кодировку из Content-Type, используем UTF-8");
}
}
// Читаем тело POST-запроса
string requestBody;
using (var reader = new StreamReader(context.Request.InputStream, encoding))
{
requestBody = await reader.ReadToEndAsync();
}
var formDict = new Dictionary<string, string>();
try
{
var formData = System.Web.HttpUtility.ParseQueryString(requestBody, encoding);
Debug.Log("\n=== Данные через ParseQueryString ===");
foreach (string key in formData.AllKeys)
{
if (key != null)
{
string value = formData[key] ?? "";
formDict[key] = value;
Debug.Log($" {key} = {formDict[key]}");
if (key.Contains("%"))
{
string decodedKey = Uri.UnescapeDataString(key);
Debug.Log($"РУЧНОЙ ДЕКОД: {decodedKey}");
}
}
formDict[key] = formData[key] ?? "";
}
}
catch (Exception parseEx)
@ -219,66 +365,46 @@ public class WebServer : MonoBehaviour
Debug.LogWarning($"ParseQueryString ошибка: {parseEx.Message}");
}
SettingsApplier.ApplySettings(formDict);
//SettingsManager.OnSettingsChanged?.Invoke();
SettingsApplier.ApplySettings(formDict, assumeMissingBoolOff: true);
UnityMainThreadDispatcher.Instance.Enqueue(() => SettingsManager.OnSettingsChanged?.Invoke());
UnityMainThreadDispatcher.Instance.Enqueue(() => GameManager.instance.AdminPanel.GetComponent<SettingsScreen>().RefreshViews());
// Отправляем ответ обратно
string responseHtml = @"
<html>
<head>
<meta charset='UTF-8'>
<title>Обновлено</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
.success { color: green; font-weight: bold; }
.info { margin: 20px 0; padding: 10px; background: #f0f0f0; }
</style>
</head>
<body>
<h1 class='success'>✓ Данные успешно получены!</h1>
<div class='info'>
<p>Сервер обработал ваш запрос.</p>
<p>Полученные данные:</p>
<ul>";
// Добавляем список полученных данных в ответ
foreach (var kvp in formDict)
{
responseHtml += $"<li><strong>{kvp.Key}</strong>: {kvp.Value}</li>";
}
responseHtml += @"
</ul>
</div>
<a href='/' style='display: inline-block; margin-top: 20px; padding: 10px 20px; background: #007bff; color: white; text-decoration: none; border-radius: 5px;'>
Вернуться к настройкам
</a>
<script>
// Через 5 секунд возвращаемся назад
setTimeout(function() {
window.location.href = '/';
}, 5000);
</script>
</body>
</html>";
SendHtmlResponse(context, responseHtml);
SendHtmlResponse(context, "<html><body><h1>OK</h1><a href='/'>Back</a></body></html>");
}
catch (Exception ex)
{
Debug.LogError($"Ошибка обработки POST /upd: {ex.Message}");
Debug.LogError($"StackTrace: {ex.StackTrace}");
SendHtmlResponse(context,
$"<html><head><meta charset='UTF-8'><title>Ошибка</title></head><body>" +
$"<h1 style='color:red;'>Ошибка: {ex.Message}</h1>" +
$"<pre>{ex.StackTrace}</pre>" +
$"<a href='/'>Вернуться назад</a></body></html>",
500);
Debug.LogError($"Ошибка обработки POST /upd: {ex.Message}\n{ex.StackTrace}");
SendHtmlResponse(context, $"<h1 style='color:red;'>Ошибка: {ex.Message}</h1>", 500);
}
}
}
// =========================
// Helpers
// =========================
private async Task<string> ReadBodyAsStringAsync(HttpListenerRequest request)
{
Encoding encoding = Encoding.UTF8;
// иногда Unity/браузер шлет json без charset — ок
if (!string.IsNullOrEmpty(request.ContentType))
{
var ct = request.ContentType.ToLower();
if (ct.Contains("charset="))
{
try
{
string charset = ct.Split("charset=")[1].Trim().Split(';')[0];
encoding = Encoding.GetEncoding(charset);
}
catch { encoding = Encoding.UTF8; }
}
}
using var reader = new StreamReader(request.InputStream, encoding);
return await reader.ReadToEndAsync();
}
void SendHtmlResponse(HttpListenerContext context, string html, int statusCode = 200)
{
@ -297,6 +423,30 @@ public class WebServer : MonoBehaviour
}
}
private void SendJson(HttpListenerContext context, int statusCode, string json)
{
try
{
byte[] buffer = Encoding.UTF8.GetBytes(json);
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json; charset=utf-8";
context.Response.ContentLength64 = buffer.Length;
context.Response.OutputStream.Write(buffer, 0, buffer.Length);
context.Response.OutputStream.Close();
}
catch (Exception ex)
{
Debug.LogError($"Error sending json: {ex.Message}");
}
}
private void Redirect(HttpListenerContext context, string location, int statusCode = 302)
{
context.Response.StatusCode = statusCode;
context.Response.Headers["Location"] = location;
context.Response.OutputStream.Close();
}
void OnDestroy()
{
isRunning = false;
@ -310,4 +460,4 @@ public class WebServer : MonoBehaviour
listener?.Stop();
listener?.Close();
}
}
}