From 2f50097250eb554ba2786c26ed3b138cf4f93645 Mon Sep 17 00:00:00 2001 From: linmeideli <2558527373@qq.com> Date: Thu, 1 Oct 2026 10:07:18 +0800 Subject: [PATCH] Update Chat Plugin --- marketplace/Empostor.Plugins.PlayerLog.dll | Bin 22016 -> 28672 bytes marketplace/themes.json | 65 +++ marketplace/themes/anthropic.json | 38 ++ marketplace/themes/catppuccin.json | 38 ++ marketplace/themes/dracula.json | 38 ++ marketplace/themes/gruvbox.json | 38 ++ marketplace/themes/midnight.json | 38 ++ marketplace/themes/nord.json | 38 ++ marketplace/themes/rose-pine.json | 38 ++ marketplace/themes/solarized.json | 38 ++ marketplace/themes/tokyo-night.json | 38 ++ src/Empostor.Api/Config/AdminConfig.cs | 5 + src/Empostor.Api/Config/HplpConfig.cs | 5 +- src/Empostor.Api/Service/JsonDataStore.cs | 11 +- .../ChatAdminExtension.cs | 145 ++++++ src/Empostor.Plugin.Chat/ChatConfig.cs | 55 ++ src/Empostor.Plugin.Chat/ChatService.cs | 48 +- src/Empostor.Plugin.Chat/ChatStartup.cs | 3 + src/Empostor.Plugin.Chat/ChatStore.cs | 95 ++++ .../PlayerLogAdminExtension.cs | 135 ++++- .../PlayerLogPluginStartup.cs | 44 +- src/Empostor.Server/Empostor.Server.csproj | 7 +- .../Http/Admin/AdminSession.cs | 17 + .../Http/Admin/AdminThemeRegistry.cs | 48 +- src/Empostor.Server/Http/AdminController.cs | 94 +--- .../Http/AdminTemplateDefaults.cs | 445 ++++++++++------ .../Http/MarketplaceController.cs | 203 +++++++- src/Empostor.Server/Http/UpdateController.cs | 473 ++++++++++++++++++ src/Empostor.Server/Pages/AdminStrings.json | 35 +- .../Pages/themes/catppuccin/theme.json | 38 ++ .../Pages/themes/dracula/theme.json | 38 ++ .../Pages/themes/gruvbox/theme.json | 38 ++ .../Pages/themes/midnight/theme.json | 38 ++ .../Pages/themes/nord/theme.json | 38 ++ .../Pages/themes/rose-pine/theme.json | 38 ++ .../Pages/themes/solarized/theme.json | 38 ++ .../Pages/themes/tokyo-night/theme.json | 38 ++ src/Empostor.Server/Program.cs | 1 - src/Empostor.Server/Service/Api/HplpStore.cs | 19 +- src/Empostor.Server/config.json | 6 - 40 files changed, 2242 insertions(+), 363 deletions(-) create mode 100644 marketplace/themes.json create mode 100644 marketplace/themes/anthropic.json create mode 100644 marketplace/themes/catppuccin.json create mode 100644 marketplace/themes/dracula.json create mode 100644 marketplace/themes/gruvbox.json create mode 100644 marketplace/themes/midnight.json create mode 100644 marketplace/themes/nord.json create mode 100644 marketplace/themes/rose-pine.json create mode 100644 marketplace/themes/solarized.json create mode 100644 marketplace/themes/tokyo-night.json create mode 100644 src/Empostor.Plugin.Chat/ChatAdminExtension.cs create mode 100644 src/Empostor.Plugin.Chat/ChatStore.cs create mode 100644 src/Empostor.Server/Http/Admin/AdminSession.cs create mode 100644 src/Empostor.Server/Http/UpdateController.cs create mode 100644 src/Empostor.Server/Pages/themes/catppuccin/theme.json create mode 100644 src/Empostor.Server/Pages/themes/dracula/theme.json create mode 100644 src/Empostor.Server/Pages/themes/gruvbox/theme.json create mode 100644 src/Empostor.Server/Pages/themes/midnight/theme.json create mode 100644 src/Empostor.Server/Pages/themes/nord/theme.json create mode 100644 src/Empostor.Server/Pages/themes/rose-pine/theme.json create mode 100644 src/Empostor.Server/Pages/themes/solarized/theme.json create mode 100644 src/Empostor.Server/Pages/themes/tokyo-night/theme.json diff --git a/marketplace/Empostor.Plugins.PlayerLog.dll b/marketplace/Empostor.Plugins.PlayerLog.dll index 7a7687d98dd4901360551f4f0c236db40c8c59a2..f4411be6285c51f96c83cea22d74e673c36c709f 100644 GIT binary patch literal 28672 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 k;}m{W^5*_+PZQ-99c7X`|8xKk*fdBvi diff --git a/marketplace/themes.json b/marketplace/themes.json new file mode 100644 index 0000000..498b649 --- /dev/null +++ b/marketplace/themes.json @@ -0,0 +1,65 @@ +[ + { + "id": "a/", + "name": "Anthropic", + "description": "The theme like anthropic design", + "author": "Empostor", + "downloadUrl": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes/anthropic.json" + }, + { + "id": "nord", + "name": "Nord", + "description": "Cool polar blues with muted arctic greys. Calm, low-contrast and easy on the eyes.", + "author": "Empostor", + "downloadUrl": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes/nord.json" + }, + { + "id": "dracula", + "name": "Dracula", + "description": "Purple-accented dark palette with rounded cards and soft glow highlights.", + "author": "Empostor", + "downloadUrl": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes/dracula.json" + }, + { + "id": "solarized", + "name": "Solarized", + "description": "Precision-tuned warm beige and deep teal pair with a monospace typeface.", + "author": "Empostor", + "downloadUrl": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes/solarized.json" + }, + { + "id": "gruvbox", + "name": "Gruvbox", + "description": "Retro warm earth tones, chunky two-pixel borders and bold labels.", + "author": "Empostor", + "downloadUrl": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes/gruvbox.json" + }, + { + "id": "tokyo-night", + "name": "Tokyo Night", + "description": "Neon-lit midnight blues with gradient surfaces and a soft accent glow.", + "author": "Empostor", + "downloadUrl": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes/tokyo-night.json" + }, + { + "id": "catppuccin", + "name": "Catppuccin", + "description": "Pastel palette with heavily rounded pill buttons and gentle shadows.", + "author": "Empostor", + "downloadUrl": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes/catppuccin.json" + }, + { + "id": "rose-pine", + "name": "Rosé Pine", + "description": "Dusty rose and iris accents on a warm serif-heavy layout.", + "author": "Empostor", + "downloadUrl": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes/rose-pine.json" + }, + { + "id": "midnight", + "name": "Midnight OLED", + "description": "True-black AMOLED panels with a high-contrast accent for late-night shifts.", + "author": "Empostor", + "downloadUrl": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes/midnight.json" + } +] diff --git a/marketplace/themes/anthropic.json b/marketplace/themes/anthropic.json new file mode 100644 index 0000000..5add65b --- /dev/null +++ b/marketplace/themes/anthropic.json @@ -0,0 +1,38 @@ +{ + "Id": "anthropic", + "Name": "Anthropic", + "Extends": null, + "Tokens": { + "--bg": "#FAF9F5", + "--s": "#F1EFE8", + "--b": "#E4E0D4", + "--t": "#1F1E1B", + "--m": "#6E6A61", + "--a": "#CC785C", + "--g": "#3D7A4E", + "--y": "#B7791F", + "--r": "#C2412D", + "--p": "#7C5CBF", + "--o": "#C2603F", + "--field": "#FFFFFF", + "--hover": "rgba(31,30,27,.05)", + "--on-a": "#FFFFFF" + }, + "DarkTokens": { + "--bg": "#1A1917", + "--s": "#242321", + "--b": "#34312D", + "--t": "#ECEAE4", + "--m": "#A8A49B", + "--a": "#D97757", + "--g": "#6FB37E", + "--y": "#D4A017", + "--r": "#E0654F", + "--p": "#A78BFA", + "--o": "#E08A5F", + "--field": "#242321", + "--hover": "rgba(255,255,255,.05)", + "--on-a": "#FFFFFF" + }, + "CustomCss": "body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;letter-spacing:.1px}\nh1,h2,h3,.nlbl{font-family:ui-serif,Georgia,'Iowan Old Style','Times New Roman',serif;font-weight:600;letter-spacing:.2px}\nh1,h2{color:var(--t)}\nh2{padding-bottom:10px;border-bottom:1px solid var(--b);margin-bottom:16px}\n.nav,.form,.sc,table,.field{border-radius:10px}\n.nav{background:var(--s)}\n.ni{border-radius:8px;transition:background .15s ease,color .15s ease,transform .12s ease}\n.ni:hover{transform:translateX(2px)}\n.ni.active{background:var(--hover);color:var(--a);font-weight:600}\n.ni.active svg{color:var(--a)}\n.form,.sc{border:1px solid var(--b);box-shadow:0 1px 2px rgba(31,30,27,.03);transition:border-color .15s ease,box-shadow .15s ease}\n.form:hover,.sc:hover{border-color:var(--a);box-shadow:0 2px 10px rgba(204,120,92,.08)}\n.pnl.active{animation:antIn .28s ease}\n@keyframes antIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:8px;font-weight:600;letter-spacing:.2px;transition:transform .1s ease,box-shadow .12s ease,opacity .12s ease}\nbutton.bp:hover,button.bd:hover,button.bw:hover,button.bsm:hover{transform:translateY(-1px)}\nbutton.bp{background:var(--a);box-shadow:0 1px 3px rgba(204,120,92,.35)}\nbutton.bp:hover{box-shadow:0 3px 10px rgba(204,120,92,.4)}\ninput,select,textarea{border-radius:7px}\ninput:focus,select:focus,textarea:focus{border-color:var(--a);box-shadow:0 0 0 3px rgba(204,120,92,.18)}\ntable{border:1px solid var(--b);overflow:hidden}\nth{background:var(--s);font-weight:600}\ntd,th{border-bottom:1px solid var(--b)}\n.sc .v{color:var(--a);font-family:ui-serif,Georgia,serif}\n.theme-toggle,.theme-sel{border-radius:8px}\n.msg.ok{border-left:3px solid var(--g)}\n.msg.err{border-left:3px solid var(--r)}" +} diff --git a/marketplace/themes/catppuccin.json b/marketplace/themes/catppuccin.json new file mode 100644 index 0000000..55fd067 --- /dev/null +++ b/marketplace/themes/catppuccin.json @@ -0,0 +1,38 @@ +{ + "Id": "catppuccin", + "Name": "Catppuccin", + "Extends": null, + "Tokens": { + "--bg": "#EFF1F5", + "--s": "#E6E9EF", + "--b": "#DCE0E8", + "--t": "#4C4F69", + "--m": "#6C6F85", + "--a": "#1E66F5", + "--g": "#40A02B", + "--y": "#DF8E1D", + "--r": "#D20F39", + "--p": "#8839EF", + "--o": "#FE640B", + "--field": "#FFFFFF", + "--hover": "rgba(76,79,105,.05)", + "--on-a": "#FFFFFF" + }, + "DarkTokens": { + "--bg": "#1E1E2E", + "--s": "#313244", + "--b": "#45475A", + "--t": "#CDD6F4", + "--m": "#A6ADC8", + "--a": "#89B4FA", + "--g": "#A6E3A1", + "--y": "#F9E2AF", + "--r": "#F38BA8", + "--p": "#CBA6F7", + "--o": "#FAB387", + "--field": "#181825", + "--hover": "rgba(205,214,244,.06)", + "--on-a": "#1E1E2E" + }, + "CustomCss": "body{letter-spacing:.15px}\nh2{padding-bottom:8px;border-bottom:1px solid var(--b)}\n.form,.sc,.ig,table{border-radius:14px}\n.form,.sc{border:1px solid var(--b);box-shadow:0 1px 3px rgba(76,79,105,.05)}\n.form:hover,.sc:hover{border-color:var(--a);box-shadow:0 4px 16px rgba(137,180,250,.14)}\n.badge{border-radius:999px;padding:3px 10px;font-weight:600}\n.chip{border-radius:999px}\n.ni{border-radius:0 12px 12px 0;transition:background .15s ease,color .15s ease}\n.ni:hover{background:var(--hover)}\n.ni.active{background:rgba(137,180,250,.14);font-weight:600}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:999px;font-weight:600;padding-left:18px;padding-right:18px;transition:transform .12s ease,box-shadow .12s ease}\nbutton.bp:hover,button.bd:hover,button.bw:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(76,79,105,.18)}\ninput,select,textarea{border-radius:10px}\ninput:focus,select:focus,textarea:focus{border-color:var(--a);box-shadow:0 0 0 3px rgba(137,180,250,.18)}\nth{background:var(--s);font-weight:600}" +} diff --git a/marketplace/themes/dracula.json b/marketplace/themes/dracula.json new file mode 100644 index 0000000..9fe2316 --- /dev/null +++ b/marketplace/themes/dracula.json @@ -0,0 +1,38 @@ +{ + "Id": "dracula", + "Name": "Dracula", + "Extends": null, + "Tokens": { + "--bg": "#F7F7FB", + "--s": "#EFEFF6", + "--b": "#DFDFEC", + "--t": "#282A36", + "--m": "#6272A4", + "--a": "#7C4DFF", + "--g": "#1E9E52", + "--y": "#B08400", + "--r": "#D63031", + "--p": "#C2185B", + "--o": "#C4601A", + "--field": "#FFFFFF", + "--hover": "rgba(40,42,54,.05)", + "--on-a": "#FFFFFF" + }, + "DarkTokens": { + "--bg": "#282A36", + "--s": "#323444", + "--b": "#44475A", + "--t": "#F8F8F2", + "--m": "#A3ACCC", + "--a": "#BD93F9", + "--g": "#50FA7B", + "--y": "#F1FA8C", + "--r": "#FF5555", + "--p": "#FF79C6", + "--o": "#FFB86C", + "--field": "#21222C", + "--hover": "rgba(248,248,242,.06)", + "--on-a": "#282A36" + }, + "CustomCss": "body{letter-spacing:.15px}\nh2{padding-bottom:8px;border-bottom:1px solid var(--b)}\n.form,.sc,.ig,table{border-radius:12px}\n.form,.sc{box-shadow:0 1px 2px rgba(40,42,54,.06)}\n.form:hover{border-color:var(--a);box-shadow:0 3px 14px rgba(189,147,249,.14)}\n.ni{border-radius:10px;transition:background .15s ease,color .15s ease}\n.ni:hover{background:var(--hover)}\n.ni.active{background:rgba(189,147,249,.14);color:var(--a);font-weight:600}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:9px;font-weight:600;transition:transform .1s ease}\nbutton.bp:hover,button.bd:hover,button.bw:hover{transform:translateY(-1px)}\ninput,select,textarea{border-radius:9px}\ninput:focus,select:focus,textarea:focus{box-shadow:0 0 0 3px rgba(189,147,249,.22)}\n.pnl.active{animation:fadeIn .25s ease}\n@keyframes fadeIn{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}" +} diff --git a/marketplace/themes/gruvbox.json b/marketplace/themes/gruvbox.json new file mode 100644 index 0000000..f2ddc83 --- /dev/null +++ b/marketplace/themes/gruvbox.json @@ -0,0 +1,38 @@ +{ + "Id": "gruvbox", + "Name": "Gruvbox", + "Extends": null, + "Tokens": { + "--bg": "#FBF1C7", + "--s": "#F2E5BC", + "--b": "#E0CFA0", + "--t": "#3C3836", + "--m": "#7C6F64", + "--a": "#AF3A03", + "--g": "#79740E", + "--y": "#B57614", + "--r": "#9D0006", + "--p": "#8F3F71", + "--o": "#D65D0E", + "--field": "#FFFBF0", + "--hover": "rgba(60,56,54,.06)", + "--on-a": "#FBF1C7" + }, + "DarkTokens": { + "--bg": "#282828", + "--s": "#3C3836", + "--b": "#504945", + "--t": "#EBDBB2", + "--m": "#A89984", + "--a": "#FE8019", + "--g": "#B8BB26", + "--y": "#FABD2F", + "--r": "#FB4934", + "--p": "#D3869B", + "--o": "#D65D0E", + "--field": "#1D2021", + "--hover": "rgba(235,219,178,.06)", + "--on-a": "#282828" + }, + "CustomCss": "body{letter-spacing:.1px}\nh2{padding-bottom:8px;border-bottom:2px solid var(--b)}\n.form,.sc,.ig,table{border-radius:6px;border-width:2px}\n.ni{border-radius:0 8px 8px 0}\n.ni.active{font-weight:700}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:6px;font-weight:700;text-transform:none}\ninput,select,textarea{border-radius:6px;border-width:2px}\nth{font-weight:700;letter-spacing:.6px}\n.badge{border-radius:6px}\n.code{font-weight:700}" +} diff --git a/marketplace/themes/midnight.json b/marketplace/themes/midnight.json new file mode 100644 index 0000000..32d1414 --- /dev/null +++ b/marketplace/themes/midnight.json @@ -0,0 +1,38 @@ +{ + "Id": "midnight", + "Name": "Midnight OLED", + "Extends": null, + "Tokens": { + "--bg": "#F5F7FA", + "--s": "#EBEFF5", + "--b": "#D9E0EA", + "--t": "#0B1220", + "--m": "#5A6B85", + "--a": "#0A84FF", + "--g": "#1FA84F", + "--y": "#B57A00", + "--r": "#E5484D", + "--p": "#8E4EC6", + "--o": "#C4601A", + "--field": "#FFFFFF", + "--hover": "rgba(11,18,32,.05)", + "--on-a": "#FFFFFF" + }, + "DarkTokens": { + "--bg": "#000000", + "--s": "#0B0F14", + "--b": "#1C232C", + "--t": "#FFFFFF", + "--m": "#8B98A9", + "--a": "#4C9AFF", + "--g": "#3DDC84", + "--y": "#FFD60A", + "--r": "#FF453A", + "--p": "#BF5AF2", + "--o": "#FF9F0A", + "--field": "#080B10", + "--hover": "rgba(255,255,255,.05)", + "--on-a": "#000000" + }, + "CustomCss": "[data-theme=\"dark\"] body,body{background:var(--bg)}\n[data-theme=\"dark\"] header,[data-theme=\"dark\"] nav{background:#050709}\n[data-theme=\"dark\"] .sc,[data-theme=\"dark\"] .form,[data-theme=\"dark\"] table{border-color:#1C232C}\n[data-theme=\"dark\"] .ni.active{background:rgba(76,154,255,.16)}\n[data-theme=\"dark\"] .code{color:#4C9AFF;text-shadow:0 0 10px rgba(76,154,255,.5)}\n[data-theme=\"dark\"] .dot{box-shadow:0 0 8px var(--g)}\nh2{padding-bottom:8px;border-bottom:1px solid var(--b)}\n.form,.sc,.ig,table{border-radius:8px}\n.ni{border-left-width:3px}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:6px;font-weight:600}\ninput,select,textarea{border-radius:6px}\ninput:focus,select:focus,textarea:focus{box-shadow:0 0 0 3px rgba(76,154,255,.22)}" +} diff --git a/marketplace/themes/nord.json b/marketplace/themes/nord.json new file mode 100644 index 0000000..d603b75 --- /dev/null +++ b/marketplace/themes/nord.json @@ -0,0 +1,38 @@ +{ + "Id": "nord", + "Name": "Nord", + "Extends": null, + "Tokens": { + "--bg": "#ECEFF4", + "--s": "#E5E9F0", + "--b": "#D8DEE9", + "--t": "#2E3440", + "--m": "#4C566A", + "--a": "#5E81AC", + "--g": "#4C8C4A", + "--y": "#B58A28", + "--r": "#BF616A", + "--p": "#8F6FA8", + "--o": "#D08770", + "--field": "#FFFFFF", + "--hover": "rgba(46,52,64,.05)", + "--on-a": "#FFFFFF" + }, + "DarkTokens": { + "--bg": "#2E3440", + "--s": "#3B4252", + "--b": "#4C566A", + "--t": "#ECEFF4", + "--m": "#97A1B5", + "--a": "#88C0D0", + "--g": "#A3BE8C", + "--y": "#EBCB8B", + "--r": "#BF616A", + "--p": "#B48EAD", + "--o": "#D08770", + "--field": "#2B3140", + "--hover": "rgba(236,239,244,.06)", + "--on-a": "#2E3440" + }, + "CustomCss": "body{letter-spacing:.1px}\nh2{border-bottom:1px solid var(--b);padding-bottom:8px}\n.form,.sc,.ig,table{border-radius:8px}\n.ni{border-radius:0 8px 8px 0}\nbutton.bp,button.bd,button.bw{border-radius:7px;letter-spacing:.2px}\ninput:focus,select:focus,textarea:focus{box-shadow:0 0 0 3px rgba(94,129,172,.2)}\n.hdr{background:var(--s)}" +} diff --git a/marketplace/themes/rose-pine.json b/marketplace/themes/rose-pine.json new file mode 100644 index 0000000..e6e8106 --- /dev/null +++ b/marketplace/themes/rose-pine.json @@ -0,0 +1,38 @@ +{ + "Id": "rose-pine", + "Name": "Rosé Pine", + "Extends": null, + "Tokens": { + "--bg": "#FAF4ED", + "--s": "#F2E9E1", + "--b": "#E4DAD2", + "--t": "#575279", + "--m": "#797593", + "--a": "#286983", + "--g": "#56949F", + "--y": "#EA9D34", + "--r": "#B4637A", + "--p": "#907AA9", + "--o": "#D7827E", + "--field": "#FFFCF9", + "--hover": "rgba(87,82,121,.05)", + "--on-a": "#FFFFFF" + }, + "DarkTokens": { + "--bg": "#191724", + "--s": "#1F1D2E", + "--b": "#26233A", + "--t": "#E0DEF4", + "--m": "#908CAA", + "--a": "#C4A7E7", + "--g": "#31748F", + "--y": "#F6C177", + "--r": "#EB6F92", + "--p": "#C4A7E7", + "--o": "#EA9D34", + "--field": "#12111A", + "--hover": "rgba(224,222,244,.06)", + "--on-a": "#191724" + }, + "CustomCss": "body{font-family:'Georgia','Iowan Old Style',ui-serif,serif;letter-spacing:.15px}\nh1,h2,h3,.nlbl{font-family:ui-serif,Georgia,serif}\nh2{padding-bottom:8px;border-bottom:1px solid var(--b)}\n.form,.sc,.ig,table{border-radius:16px}\n.form,.sc{box-shadow:0 1px 4px rgba(87,82,121,.05)}\n.ni{border-radius:0 12px 12px 0}\n.ni.active{background:rgba(196,167,231,.14);font-weight:600}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:12px;font-weight:600}\ninput,select,textarea{border-radius:12px}\ninput:focus,select:focus,textarea:focus{border-color:var(--a);box-shadow:0 0 0 3px rgba(196,167,231,.2)}\nth{font-style:italic;font-weight:600}\n.pnl.active{animation:rpIn .3s ease}\n@keyframes rpIn{from{opacity:0}to{opacity:1}}" +} diff --git a/marketplace/themes/solarized.json b/marketplace/themes/solarized.json new file mode 100644 index 0000000..6cb37a7 --- /dev/null +++ b/marketplace/themes/solarized.json @@ -0,0 +1,38 @@ +{ + "Id": "solarized", + "Name": "Solarized", + "Extends": null, + "Tokens": { + "--bg": "#FDF6E3", + "--s": "#EEE8D5", + "--b": "#DDD6C1", + "--t": "#073642", + "--m": "#657B83", + "--a": "#268BD2", + "--g": "#7A8C00", + "--y": "#B58900", + "--r": "#DC322F", + "--p": "#6C71C4", + "--o": "#CB4B16", + "--field": "#FFFBF0", + "--hover": "rgba(7,54,66,.05)", + "--on-a": "#FFFFFF" + }, + "DarkTokens": { + "--bg": "#002B36", + "--s": "#073642", + "--b": "#14505F", + "--t": "#EEE8D5", + "--m": "#93A1A1", + "--a": "#3FA3E8", + "--g": "#B4C43A", + "--y": "#D4A72C", + "--r": "#E85550", + "--p": "#8B90E0", + "--o": "#E5763F", + "--field": "#00252E", + "--hover": "rgba(238,232,213,.06)", + "--on-a": "#002B36" + }, + "CustomCss": "body{font-family:'Menlo','Consolas','DejaVu Sans Mono',ui-monospace,monospace;font-size:13px}\nh1,h2{font-weight:600}\nh2{padding-bottom:8px;border-bottom:1px solid var(--b)}\n.form,.sc,.ig,table{border-radius:4px}\n.ni{border-left-width:3px}\nth{background:var(--s);font-weight:700}\nbutton.bp,button.bd,button.bw{border-radius:4px;font-weight:600}\ninput,select,textarea{border-radius:4px}\n.code,.ip,td.mono{font-weight:600}" +} diff --git a/marketplace/themes/tokyo-night.json b/marketplace/themes/tokyo-night.json new file mode 100644 index 0000000..d815186 --- /dev/null +++ b/marketplace/themes/tokyo-night.json @@ -0,0 +1,38 @@ +{ + "Id": "tokyo-night", + "Name": "Tokyo Night", + "Extends": null, + "Tokens": { + "--bg": "#F6F7FB", + "--s": "#EDEFF7", + "--b": "#DCE0EC", + "--t": "#1A1B26", + "--m": "#565A6E", + "--a": "#2E5FA3", + "--g": "#3D7A33", + "--y": "#8F5E15", + "--r": "#8C4351", + "--p": "#5A4A78", + "--o": "#965027", + "--field": "#FFFFFF", + "--hover": "rgba(26,27,38,.05)", + "--on-a": "#FFFFFF" + }, + "DarkTokens": { + "--bg": "#1A1B26", + "--s": "#24283B", + "--b": "#343B58", + "--t": "#C0CAF5", + "--m": "#7C86B0", + "--a": "#7AA2F7", + "--g": "#9ECE6A", + "--y": "#E0AF68", + "--r": "#F7768E", + "--p": "#BB9AF7", + "--o": "#FF9E64", + "--field": "#16161E", + "--hover": "rgba(192,202,245,.06)", + "--on-a": "#1A1B26" + }, + "CustomCss": "body{letter-spacing:.1px}\nh2{padding-bottom:8px;border-bottom:1px solid var(--b);letter-spacing:.6px}\n.form,.sc,.ig,table{border-radius:10px}\n.sc{background:linear-gradient(160deg,var(--s),transparent)}\n.ni{border-radius:8px;transition:background .18s ease,color .18s ease}\n.ni:hover{background:var(--hover);transform:translateX(2px)}\n.ni.active{background:rgba(122,162,247,.12)}\n.form:hover{border-color:var(--a)}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:8px;font-weight:600;transition:transform .12s ease,box-shadow .12s ease}\nbutton.bp:hover,button.bd:hover,button.bw:hover{transform:translateY(-1px)}\nbutton.bp{box-shadow:0 1px 3px rgba(122,162,247,.3)}\ninput,select,textarea{border-radius:8px}\ninput:focus,select:focus,textarea:focus{box-shadow:0 0 0 3px rgba(122,162,247,.2)}\n.code{text-shadow:0 0 8px rgba(122,162,247,.35)}\n.pnl.active{animation:tnIn .25s ease}\n@keyframes tnIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}" +} diff --git a/src/Empostor.Api/Config/AdminConfig.cs b/src/Empostor.Api/Config/AdminConfig.cs index 8d65c0f..da68e59 100644 --- a/src/Empostor.Api/Config/AdminConfig.cs +++ b/src/Empostor.Api/Config/AdminConfig.cs @@ -9,6 +9,11 @@ namespace Empostor.Api.Config public string MarketplaceUrl { get; set; } = "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/plugins.json"; + public string ThemeMarketplaceUrl { get; set; } = + "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes.json"; + + public string GitHubToken { get; set; } = string.Empty; + /// Active admin theme id. Themes live under Pages/themes/{Id} or as plugins. public string Theme { get; set; } = "default"; diff --git a/src/Empostor.Api/Config/HplpConfig.cs b/src/Empostor.Api/Config/HplpConfig.cs index 5d6a098..d7e4577 100644 --- a/src/Empostor.Api/Config/HplpConfig.cs +++ b/src/Empostor.Api/Config/HplpConfig.cs @@ -1,13 +1,10 @@ namespace Empostor.Api.Config { /// - /// Configuration for the HPLP (HTTP Public Lobby List Protocol) endpoint, - /// which allows Starlight clients to discover games hosted on this server. + /// HPLP settings, which let Starlight clients discover games hosted on this server. /// public class HplpConfig { - public const string Section = "HPLP"; - /// /// Whether the HPLP endpoint (GET /x-api/games) is enabled. /// diff --git a/src/Empostor.Api/Service/JsonDataStore.cs b/src/Empostor.Api/Service/JsonDataStore.cs index b6f1ba2..578c690 100644 --- a/src/Empostor.Api/Service/JsonDataStore.cs +++ b/src/Empostor.Api/Service/JsonDataStore.cs @@ -93,12 +93,16 @@ public abstract class JsonDataStore : IDisposable /// Load data from the file on disk. If the file does not exist or fails to parse, /// the store remains in its default (constructor-initialized) state. /// - protected void Load() + /// + /// true when a persisted snapshot was applied, false when the store kept its + /// constructor defaults (no file yet or unreadable). Callers use this to seed from config. + /// + protected bool Load() { if (!File.Exists(FilePath)) { Logger.LogDebug("{Name} no data file at {Path}, using defaults", GetType().Name, FilePath); - return; + return false; } try @@ -109,12 +113,15 @@ public abstract class JsonDataStore : IDisposable { ApplySnapshot(data); Logger.LogInformation("{Name} loaded from {Path}", GetType().Name, FilePath); + return true; } } catch (Exception ex) { Logger.LogWarning(ex, "{Name} failed to load {Path}, using defaults", GetType().Name, FilePath); } + + return false; } /// diff --git a/src/Empostor.Plugin.Chat/ChatAdminExtension.cs b/src/Empostor.Plugin.Chat/ChatAdminExtension.cs new file mode 100644 index 0000000..506a6a9 --- /dev/null +++ b/src/Empostor.Plugin.Chat/ChatAdminExtension.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Empostor.Api.Admin; + +namespace Empostor.Plugin.Chat; + +public sealed class ChatAdminExtension : IAdminExtension +{ + private const int MessagePreviewLength = 220; + + private readonly ChatStore _store; + private readonly ChatConfig _config; + + private string _room = string.Empty; + private bool _paused; + private List _frozen = new(); + + public ChatAdminExtension(ChatStore store) + { + _store = store; + _config = ChatConfig.Load(); + } + + public string Id => "chat-monitor"; + + public string Title => "Chat Monitor"; + + public string Icon => "chat"; + + public string Section => "Server"; + + public void Build(AdminPanelBuilder b) + { + var visible = Math.Max(10, _config.MaxVisibleMessages); + var entries = _paused ? _frozen : _store.Recent(_room, visible); + + b.RegisterSelect(sel => + { + sel.Label = "Room"; + sel.Options = new List { new("All rooms", string.Empty) }; + foreach (var room in _store.Rooms()) + { + sel.Options.Add(new AdminOption(room, room)); + } + + sel.Value = _room; + sel.OnChange(ctx => + { + _room = ctx.Value ?? string.Empty; + if (_paused) + { + _frozen = _store.Recent(_room, visible); + } + + return ValueTask.FromResult(AdminActionResult.Ok(null)); + }); + }); + + b.RegisterToggle(t => + { + t.Label = "Pause auto-refresh (the list stops moving while you read)"; + t.Value = _paused; + t.OnChange(ctx => + { + var pause = string.Equals(ctx.Value, "true", StringComparison.OrdinalIgnoreCase); + if (pause && !_paused) + { + _frozen = _store.Recent(_room, visible); + } + + _paused = pause; + return ValueTask.FromResult(AdminActionResult.Ok( + pause ? "Paused. Press Refresh to pull the latest lines." : "Resumed — updating every second.")); + }); + }); + + b.RegisterTable(t => + { + t.Columns = new List { "Time", "Room", "Player", "Channel", "Message" }; + foreach (var entry in entries) + { + t.Rows.Add(new List + { + new() { Text = entry.Time.ToLocalTime().ToString("HH:mm:ss"), Monospace = true }, + new() { Text = entry.GameCode, Monospace = true, Tone = "accent" }, + new() + { + Text = string.IsNullOrEmpty(entry.FriendCode) + ? entry.PlayerName + : $"{entry.PlayerName} ({entry.FriendCode})", + }, + new() { Text = entry.Channel, Tone = entry.Blocked ? "danger" : "muted" }, + new() { Text = Preview(entry.Message) }, + }); + } + }); + + b.RegisterText(txt => + { + txt.Tone = "muted"; + txt.Content = _store.Count == 0 + ? "No chat captured yet. Lines appear here as players talk in game." + : $"Showing {entries.Count} of {_store.Count} buffered line(s), buffer limit {_store.Capacity}" + + (_paused ? " · PAUSED" : " · live"); + }); + + b.RegisterButton(btn => + { + btn.Label = "Refresh"; + btn.Icon = "refresh"; + btn.Style = "secondary"; + btn.OnClick(ctx => + { + if (!_paused) + { + return ValueTask.FromResult(AdminActionResult.Ok("Live view — already updating every second.")); + } + + _frozen = _store.Recent(_room, visible); + return ValueTask.FromResult(AdminActionResult.Ok("Snapshot refreshed.")); + }); + }); + + b.RegisterButton(btn => + { + btn.Label = "Clear Buffer"; + btn.Icon = "trash"; + btn.Style = "danger"; + btn.OnClick(ctx => + { + _store.Clear(); + _frozen = new List(); + return ValueTask.FromResult(AdminActionResult.Ok("Chat buffer cleared.")); + }); + }); + } + + /// Keeps long or multi-line messages from blowing up the table layout. + private static string Preview(string message) + { + var flat = message.Replace('\r', ' ').Replace('\n', ' ').Trim(); + return flat.Length > MessagePreviewLength ? flat.Substring(0, MessagePreviewLength) + "…" : flat; + } +} diff --git a/src/Empostor.Plugin.Chat/ChatConfig.cs b/src/Empostor.Plugin.Chat/ChatConfig.cs index cd9e7ca..cdef704 100644 --- a/src/Empostor.Plugin.Chat/ChatConfig.cs +++ b/src/Empostor.Plugin.Chat/ChatConfig.cs @@ -1,3 +1,7 @@ +using System; +using System.IO; +using System.Text.Json; + namespace Empostor.Plugin.Chat; public sealed class ChatConfig @@ -7,4 +11,55 @@ public sealed class ChatConfig public int HostMaxMessageLength { get; set; } = 1200; public string TooLongMessage { get; set; } = "[SERVER] Couldn't send your message, it was too long."; + + /// Keep a rolling copy of in-game chat for the admin panel's Chat Monitor page. + public bool CaptureMessages { get; set; } = true; + + /// How many chat lines to keep in memory. Oldest lines are dropped first. + public int MaxBufferedMessages { get; set; } = 500; + + /// How many lines the Chat Monitor renders per refresh. + public int MaxVisibleMessages { get; set; } = 60; + + /// + /// Reads boot_chat.json next to the server executable, writing the file with defaults + /// when it is missing. Shared by and . + /// + public static ChatConfig Load() + { + var path = Path.Combine(Directory.GetCurrentDirectory(), "boot_chat.json"); + if (!File.Exists(path)) + { + try + { + File.WriteAllText( + path, + JsonSerializer.Serialize(new { Chat = new ChatConfig() }, new JsonSerializerOptions { WriteIndented = true })); + } + catch (IOException) + { + // Read-only volume: fall through to the in-memory defaults. + } + + return new ChatConfig(); + } + + try + { + var doc = JsonDocument.Parse(File.ReadAllText(path)); + if (doc.RootElement.TryGetProperty("Chat", out var chatEl)) + { + var cfg = JsonSerializer.Deserialize(chatEl.GetRawText()); + if (cfg != null) + { + return cfg; + } + } + } + catch (Exception ex) when (ex is JsonException or IOException) + { + } + + return new ChatConfig(); + } } diff --git a/src/Empostor.Plugin.Chat/ChatService.cs b/src/Empostor.Plugin.Chat/ChatService.cs index 5712cf5..cefa352 100644 --- a/src/Empostor.Plugin.Chat/ChatService.cs +++ b/src/Empostor.Plugin.Chat/ChatService.cs @@ -1,22 +1,21 @@ using System; -using System.IO; -using System.Text.Json; using Empostor.Api.Events.Player; +using Empostor.Api.Innersloth; using Microsoft.Extensions.Logging; namespace Empostor.Plugin.Chat; public sealed class ChatService { - private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true }; - private readonly ILogger _logger; private readonly ChatConfig _config; + private readonly ChatStore _store; - public ChatService(ILogger logger) + public ChatService(ILogger logger, ChatStore store) { _logger = logger; - _config = LoadConfig(); + _config = ChatConfig.Load(); + _store = store; } public void HandleChatMessage(IPlayerChatEvent e) @@ -44,6 +43,7 @@ public sealed class ChatService var isHost = e.ClientPlayer.IsHost; var maxLength = isHost ? _config.HostMaxMessageLength : _config.PlayerMaxMessageLength; + var blocked = false; if (e.Message.Length > maxLength) { @@ -53,33 +53,21 @@ public sealed class ChatService e.PlayerControl.SendChatToPlayerAsync(_config.TooLongMessage, e.PlayerControl); e.IsCancelled = true; + blocked = true; } - } - private static ChatConfig LoadConfig() - { - var path = Path.Combine(Directory.GetCurrentDirectory(), "boot_chat.json"); - if (!File.Exists(path)) + // Feed the admin panel's Chat Monitor. Off by default only if an operator opts out. + if (_config.CaptureMessages) { - var defaults = new ChatConfig(); - var json = JsonSerializer.Serialize(new { Chat = defaults }, JsonOpts); - File.WriteAllText(path, json); - return defaults; + _store.Add(new ChatEntry( + DateTime.UtcNow, + e.Game != null ? GameCodeParser.IntToGameName(e.Game.Code) : "—", + playerName, + e.ClientPlayer.Client.FriendCode, + e.ClientPlayer.Client.Id, + blocked ? "Blocked" : channelName, + e.Message, + blocked)); } - - try - { - var json = File.ReadAllText(path); - var doc = JsonDocument.Parse(json); - if (doc.RootElement.TryGetProperty("Chat", out var chatEl)) - { - var cfg = JsonSerializer.Deserialize(chatEl.GetRawText()); - if (cfg != null) - return cfg; - } - } - catch (JsonException) { } - - return new ChatConfig(); } } diff --git a/src/Empostor.Plugin.Chat/ChatStartup.cs b/src/Empostor.Plugin.Chat/ChatStartup.cs index e5a81bc..1286bd9 100644 --- a/src/Empostor.Plugin.Chat/ChatStartup.cs +++ b/src/Empostor.Plugin.Chat/ChatStartup.cs @@ -1,3 +1,4 @@ +using Empostor.Api.Admin; using Empostor.Api.Events; using Empostor.Api.Plugins; using Microsoft.Extensions.DependencyInjection; @@ -11,7 +12,9 @@ public sealed class ChatStartup : IPluginStartup public void ConfigureServices(IServiceCollection services) { + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } } diff --git a/src/Empostor.Plugin.Chat/ChatStore.cs b/src/Empostor.Plugin.Chat/ChatStore.cs new file mode 100644 index 0000000..a536228 --- /dev/null +++ b/src/Empostor.Plugin.Chat/ChatStore.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Empostor.Plugin.Chat; + +/// One captured in-game chat line. +public sealed record ChatEntry( + DateTime Time, + string GameCode, + string PlayerName, + string? FriendCode, + int ClientId, + string Channel, + string Message, + bool Blocked); + +/// +/// In-memory ring buffer of chat lines, fed by . Nothing is written to +/// disk — the admin panel is the only reader and the history is meant to be short-lived. +/// +public sealed class ChatStore +{ + private readonly object _lock = new(); + private readonly LinkedList _entries = new(); + private readonly int _capacity; + + public ChatStore() + { + var config = ChatConfig.Load(); + _capacity = Math.Max(50, config.MaxBufferedMessages); + } + + /// Oldest lines are dropped once the buffer is full. + public int Capacity => _capacity; + + public int Count + { + get + { + lock (_lock) + { + return _entries.Count; + } + } + } + + public void Add(ChatEntry entry) + { + lock (_lock) + { + _entries.AddLast(entry); + while (_entries.Count > _capacity) + { + _entries.RemoveFirst(); + } + } + } + + /// Newest first, optionally limited to one room. + public List Recent(string? gameCode, int limit) + { + lock (_lock) + { + var query = _entries.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(gameCode)) + { + query = query.Where(x => string.Equals(x.GameCode, gameCode, StringComparison.OrdinalIgnoreCase)); + } + + return query.Reverse().Take(Math.Max(1, limit)).ToList(); + } + } + + /// Room codes that currently have buffered lines, so the panel can offer a filter. + public List Rooms() + { + lock (_lock) + { + return _entries + .Select(x => x.GameCode) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + } + + public void Clear() + { + lock (_lock) + { + _entries.Clear(); + } + } +} diff --git a/src/Empostor.Plugins.PlayerLog/PlayerLogAdminExtension.cs b/src/Empostor.Plugins.PlayerLog/PlayerLogAdminExtension.cs index 0bf44b5..61bbc58 100644 --- a/src/Empostor.Plugins.PlayerLog/PlayerLogAdminExtension.cs +++ b/src/Empostor.Plugins.PlayerLog/PlayerLogAdminExtension.cs @@ -1,12 +1,27 @@ +using System; +using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Empostor.Api.Admin; namespace Empostor.Plugins.PlayerLog; +/// +/// Admin panel page for the player log. The selected player, page and clear range live on this +/// singleton, so every rebuild of the panel reflects the last choice. +/// public sealed class PlayerLogAdminExtension : IAdminExtension { + private const int PageSize = 100; + + private static readonly string[] TypeColors = { "Chat", "Report", "Murder", "Exile", "Vote", "Task", "Vent", "Meeting" }; + private readonly PlayerLogStore _store; + private int? _clientId; + private int _page = 1; + private string _clearRange = "all"; + public PlayerLogAdminExtension(PlayerLogStore store) { _store = store; @@ -22,28 +37,80 @@ public sealed class PlayerLogAdminExtension : IAdminExtension public void Build(AdminPanelBuilder b) { - var recent = _store.GetAll().Take(50).ToList(); + var players = _store.GetLoggedClientIds(); + if (_clientId.HasValue && !players.Contains(_clientId.Value)) + { + _clientId = null; + } + + var total = _clientId.HasValue ? _store.GetCountByClient(_clientId.Value) : _store.GetCount(); + var totalPages = Math.Max(1, (int)Math.Ceiling(total / (double)PageSize)); + _page = Math.Clamp(_page, 1, totalPages); + + var entries = _clientId.HasValue + ? _store.GetPageByClient(_clientId.Value, _page, PageSize) + : _store.GetPage(_page, PageSize); + + b.RegisterSelect(sel => + { + sel.Label = "Player"; + sel.Options = new List { new("All players", string.Empty) }; + foreach (var id in players) + { + var name = _store.GetLatestName(id) ?? $"Player#{id}"; + var fc = _store.GetLatestFriendCode(id) ?? "—"; + sel.Options.Add(new AdminOption($"#{id} {name} ({fc})", id.ToString())); + } + + sel.Value = _clientId?.ToString() ?? string.Empty; + sel.OnChange(ctx => + { + _clientId = int.TryParse(ctx.Value, out var id) ? id : null; + _page = 1; + return ValueTask.FromResult(AdminActionResult.Ok(null)); + }); + }); + + b.RegisterSelect(sel => + { + sel.Label = "Page"; + sel.Options = Enumerable.Range(1, totalPages) + .Select(p => new AdminOption($"{p} / {totalPages}", p.ToString())) + .ToList(); + sel.Value = _page.ToString(); + sel.OnChange(ctx => + { + if (int.TryParse(ctx.Value, out var page)) + { + _page = page; + } + + return ValueTask.FromResult(AdminActionResult.Ok(null)); + }); + }); b.RegisterTable(t => { - t.Columns = new() { "Time", "Type", "Player", "Friend Code", "Game", "Detail" }; - foreach (var e in recent) + t.Columns = new List { "Time", "Type", "Player", "Friend Code", "Game", "Detail" }; + foreach (var entry in entries) { - t.Rows.Add(new() + t.Rows.Add(new List { - new() { Text = e.Time.ToString("MM-dd HH:mm:ss") }, - new() { Text = e.Type }, - new() { Text = e.PlayerName ?? "—" }, - new() { Text = e.FriendCode ?? "—" }, - new() { Text = e.GameCode ?? "—" }, - new() { Text = e.Detail ?? "—" }, + new() { Text = entry.Time.ToLocalTime().ToString("MM-dd HH:mm:ss") }, + new() { Text = entry.Type, Tone = TypeColors.Contains(entry.Type) ? "accent" : "default" }, + new() { Text = entry.PlayerName ?? "—" }, + new() { Text = entry.FriendCode ?? "—", Monospace = true }, + new() { Text = entry.GameCode ?? "—", Monospace = true }, + new() { Text = entry.Detail ?? "—" }, }); } }); b.RegisterText(txt => { - txt.Content = $"Total entries: {_store.GetAll().Count}"; + txt.Content = total == 0 + ? "No log entries recorded yet." + : $"Showing {entries.Count} of {total} entr{(total == 1 ? "y" : "ies")} · export: /player-log/export"; txt.Tone = "muted"; }); @@ -52,22 +119,54 @@ public sealed class PlayerLogAdminExtension : IAdminExtension btn.Label = "Export as JSON"; btn.Icon = "download"; btn.Style = "secondary"; - btn.OnClick(async ctx => + btn.OnClick(ctx => { - var data = _store.ExportJson(); - return AdminActionResult.Ok($"Exported {data.Length} bytes. Download via /api/admin/player/logs/export"); + var bytes = _clientId.HasValue ? _store.ExportJson(_clientId.Value) : _store.ExportJson(); + var scope = _clientId.HasValue ? $"client {_clientId}" : "all players"; + return ValueTask.FromResult(AdminActionResult.Ok( + $"{bytes.Length} bytes ready for {scope}. Open /player-log/export to download.")); + }); + }); + + b.RegisterSelect(sel => + { + sel.Label = "Clear logs"; + sel.Options = new List + { + new("All logs", "all"), + new("Older than 1 hour", "1h"), + new("Older than 24 hours", "24h"), + new("Older than 7 days", "7d"), + new("Older than 30 days", "30d"), + }; + sel.Value = _clearRange; + sel.OnChange(ctx => + { + _clearRange = string.IsNullOrWhiteSpace(ctx.Value) ? "all" : ctx.Value!; + return ValueTask.FromResult(AdminActionResult.Ok(null)); }); }); b.RegisterButton(btn => { - btn.Label = "Clear All Logs"; + btn.Label = "Clear Logs"; btn.Icon = "trash"; btn.Style = "danger"; - btn.OnClick(async ctx => + btn.OnClick(ctx => { - _store.Clear(); - return AdminActionResult.Ok("All player logs cleared."); + var cutoff = _clearRange switch + { + "1h" => DateTime.UtcNow.AddHours(-1), + "24h" => DateTime.UtcNow.AddDays(-1), + "7d" => DateTime.UtcNow.AddDays(-7), + "30d" => DateTime.UtcNow.AddDays(-30), + _ => (DateTime?)null, + }; + + _store.Clear(cutoff); + _page = 1; + var scope = cutoff.HasValue ? $"older than {_clearRange}" : "all"; + return ValueTask.FromResult(AdminActionResult.Ok($"Cleared {scope} log entries.")); }); }); } diff --git a/src/Empostor.Plugins.PlayerLog/PlayerLogPluginStartup.cs b/src/Empostor.Plugins.PlayerLog/PlayerLogPluginStartup.cs index 563483a..43159b2 100644 --- a/src/Empostor.Plugins.PlayerLog/PlayerLogPluginStartup.cs +++ b/src/Empostor.Plugins.PlayerLog/PlayerLogPluginStartup.cs @@ -1,12 +1,19 @@ +using System; +using System.Security.Cryptography; +using System.Text; using Empostor.Api.Admin; +using Empostor.Api.Config; using Empostor.Api.Events; using Empostor.Api.Plugins; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; namespace Empostor.Plugins.PlayerLog; -public sealed class PlayerLogPluginStartup : IPluginStartup +public sealed class PlayerLogPluginStartup : IPluginStartup, IPluginHttpStartup { public void ConfigureHost(IHostBuilder host) { } @@ -16,4 +23,39 @@ public sealed class PlayerLogPluginStartup : IPluginStartup services.AddSingleton(); services.AddSingleton(); } + + public void ConfigureWebApplication(IApplicationBuilder app) + { + app.Map("/player-log/export", branch => branch.Run(async context => + { + if (!IsAdmin(context)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + var store = context.RequestServices.GetRequiredService(); + var raw = context.Request.Query["clientId"].ToString(); + var bytes = int.TryParse(raw, out var clientId) ? store.ExportJson(clientId) : store.ExportJson(); + + context.Response.ContentType = "application/json"; + context.Response.Headers["Content-Disposition"] = "attachment; filename=player_logs.json"; + await context.Response.Body.WriteAsync(bytes); + })); + } + + private static bool IsAdmin(HttpContext context) + { + var config = context.RequestServices.GetRequiredService>().Value; + if (string.IsNullOrEmpty(config.Password)) + { + return false; + } + + return context.Request.Cookies.TryGetValue("empostor_admin", out var value) + && string.Equals(value, Hash(config.Password), StringComparison.Ordinal); + } + + private static string Hash(string input) + => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input))); } diff --git a/src/Empostor.Server/Empostor.Server.csproj b/src/Empostor.Server/Empostor.Server.csproj index 95a918b..6b9bc48 100644 --- a/src/Empostor.Server/Empostor.Server.csproj +++ b/src/Empostor.Server/Empostor.Server.csproj @@ -17,7 +17,12 @@ - + + + + + diff --git a/src/Empostor.Server/Http/Admin/AdminSession.cs b/src/Empostor.Server/Http/Admin/AdminSession.cs new file mode 100644 index 0000000..8643973 --- /dev/null +++ b/src/Empostor.Server/Http/Admin/AdminSession.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Http; + +namespace Empostor.Server.Http.Admin; + +/// +/// Shared admin-session check for the controllers that expose /api/admin/* endpoints, +/// so every one of them validates the same cookie against the same password hash. +/// +internal static class AdminSession +{ + public const string CookieName = "empostor_admin"; + + public static bool IsAuthenticated(HttpContext context, string passwordHash) + => !string.IsNullOrEmpty(passwordHash) + && context.Request.Cookies.TryGetValue(CookieName, out var value) + && AdminController.ConstantTimeEquals(value, passwordHash); +} diff --git a/src/Empostor.Server/Http/Admin/AdminThemeRegistry.cs b/src/Empostor.Server/Http/Admin/AdminThemeRegistry.cs index 6872fac..5a4f4ae 100644 --- a/src/Empostor.Server/Http/Admin/AdminThemeRegistry.cs +++ b/src/Empostor.Server/Http/Admin/AdminThemeRegistry.cs @@ -9,30 +9,44 @@ using Microsoft.Extensions.Logging; namespace Empostor.Server.Http.Admin; -public sealed record AdminThemeInfo(string Id, string Name); +public sealed record AdminThemeInfo(string Id, string Name, string Source); /// -/// Resolves admin-panel themes. Themes come from compiled plugins -/// and from folders under Pages/themes/{Id}/theme.json. A theme may extend another via +/// Resolves admin-panel themes. Themes come from compiled plugins, +/// from folders under Pages/themes/{Id}/theme.json, and — at runtime — from themes +/// installed through the marketplace. A theme may extend another via /// ; tokens/css are inherited and overridden layer by layer. /// public sealed class AdminThemeRegistry { private readonly ILogger _logger; + private readonly List _compiled; private readonly Dictionary _entries = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _resolved = new(StringComparer.OrdinalIgnoreCase); public AdminThemeRegistry(IEnumerable compiled, ILogger logger) { _logger = logger; + _compiled = compiled.ToList(); + Rescan(); + } - Add("default", "Default", null, new AdminThemeDefinition()); + /// + /// Re-reads the compiled plugins and Pages/themes. Called before answering + /// theme requests so a theme dropped into the folder is usable without a restart. + /// + public void Rescan() + { + _entries.Clear(); + _resolved.Clear(); - foreach (var theme in compiled) + Add("default", "Default", "builtin", null, new AdminThemeDefinition()); + + foreach (var theme in _compiled) { try { - Add(theme.Id, theme.Name, theme.Extends, theme.Define()); + Add(theme.Id, theme.Name, "plugin", theme.Extends, theme.Define()); } catch (Exception ex) { @@ -48,11 +62,20 @@ public sealed class AdminThemeRegistry => _entries.Values .OrderBy(e => e.Id == "default" ? 0 : 1) .ThenBy(e => e.Id, StringComparer.OrdinalIgnoreCase) - .Select(e => new AdminThemeInfo(e.Id, e.Name)) + .Select(e => new AdminThemeInfo(e.Id, e.Name, e.Source)) .ToList(); + /// True when the theme ships as a folder on disk (so it can be replaced or removed). + public bool IsDiskTheme(string id) + => _entries.TryGetValue(id, out var entry) + && string.Equals(entry.Source, "disk", StringComparison.OrdinalIgnoreCase); + public bool Exists(string id) => _entries.ContainsKey(id); + /// Folder scanned for disk themes; themes are installed here as {Id}/theme.json. + public static string ThemesDirectory + => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Pages", "themes"); + public AdminThemeDefinition? Resolve(string id) => _resolved.TryGetValue(id, out var definition) ? definition : null; @@ -91,14 +114,14 @@ public sealed class AdminThemeRegistry return sb.ToString(); } - private void Add(string id, string name, string? extends, AdminThemeDefinition definition) + private void Add(string id, string name, string source, string? extends, AdminThemeDefinition definition) { - _entries[id] = new ThemeEntry { Id = id, Name = name, Extends = extends, Definition = definition }; + _entries[id] = new ThemeEntry { Id = id, Name = name, Source = source, Extends = extends, Definition = definition }; } private void LoadDiskThemes() { - var themesDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Pages", "themes"); + var themesDir = ThemesDirectory; if (!Directory.Exists(themesDir)) { return; @@ -130,7 +153,7 @@ public sealed class AdminThemeRegistry Components = disk.Components ?? new Dictionary(), }; - Add(disk.Id, disk.Name ?? disk.Id, disk.Extends, definition); + Add(disk.Id, disk.Name ?? disk.Id, "disk", disk.Extends, definition); _logger.LogInformation("Loaded admin theme '{Id}' from {Path}", disk.Id, path); } catch (Exception ex) @@ -223,6 +246,9 @@ public sealed class AdminThemeRegistry public string Name { get; set; } = string.Empty; + /// "builtin" for default, "plugin" for compiled themes, "disk" for folders. + public string Source { get; set; } = string.Empty; + public string? Extends { get; set; } public AdminThemeDefinition Definition { get; set; } = new(); diff --git a/src/Empostor.Server/Http/AdminController.cs b/src/Empostor.Server/Http/AdminController.cs index 9c77d4a..c5d536a 100644 --- a/src/Empostor.Server/Http/AdminController.cs +++ b/src/Empostor.Server/Http/AdminController.cs @@ -15,7 +15,6 @@ using Empostor.Api.Games; using Empostor.Api.Games.Managers; using Empostor.Api.Net; using Empostor.Api.Net.Manager; -using Empostor.Plugins.PlayerLog; using Empostor.Server.Http.Admin; using Empostor.Server.Service.Api; using Empostor.Server.Service.Shared; @@ -49,7 +48,6 @@ namespace Empostor.Server.Http private readonly IpGeolocationService _ipGeo; private readonly AdminExtensionRegistry _extensions; private readonly AdminThemeRegistry _themes; - private readonly PlayerLogStore? _playerLogs; private readonly string _passwordHash; private static readonly ConcurrentDictionary _loginFailures = new(); @@ -65,8 +63,7 @@ namespace Empostor.Server.Http IOptions httpServerConfig, IpGeolocationService ipGeo, AdminExtensionRegistry extensions, - AdminThemeRegistry themes, - PlayerLogStore? playerLogs = null) + AdminThemeRegistry themes) { _logger = logger; _gameManager = gameManager; @@ -80,7 +77,6 @@ namespace Empostor.Server.Http _ipGeo = ipGeo; _extensions = extensions; _themes = themes; - _playerLogs = playerLogs; _passwordHash = ComputeHash(_config.Password); if (string.IsNullOrEmpty(_config.Password) @@ -236,8 +232,8 @@ namespace Empostor.Server.Http }); } - [HttpPost("/api/admin/ext/{id}/{action}")] - public async Task InvokeExtension(string id, string action) + [HttpPost("/api/admin/ext/{id}/{actionName}")] + public async Task InvokeExtension(string id, string actionName) { if (!IsAuthenticated()) { @@ -250,7 +246,7 @@ namespace Empostor.Server.Http return NotFound(Err("Extension not found")); } - if (!builder.Actions.TryGetValue(action, out var handler)) + if (!builder.Actions.TryGetValue(actionName, out var handler)) { return NotFound(Err("Action not found")); } @@ -279,7 +275,7 @@ namespace Empostor.Server.Http // No/invalid body; the action still runs without a value. } - var context = new AdminActionContext(builder.ExtensionId, action, value, payload, HttpContext.RequestServices); + var context = new AdminActionContext(builder.ExtensionId, actionName, value, payload, HttpContext.RequestServices); try { var result = await handler(context); @@ -287,7 +283,7 @@ namespace Empostor.Server.Http } catch (Exception ex) { - _logger.LogError(ex, "Admin extension action '{Id}/{Action}' failed", id, action); + _logger.LogError(ex, "Admin extension action '{Id}/{Action}' failed", id, actionName); return Ok(AdminActionResult.Fail($"Action failed: {ex.Message}")); } } @@ -300,6 +296,8 @@ namespace Empostor.Server.Http return Unauthorized(); } + // Pick up themes dropped into Pages/themes without requiring a restart. + _themes.Rescan(); return Ok(new { themes = _themes.Themes, @@ -685,82 +683,6 @@ namespace Empostor.Server.Http }); } - [HttpGet("/api/admin/player/logs/clients")] - public IActionResult GetPlayerLogClients() - { - if (!IsAuthenticated()) return Unauthorized(); - if (_playerLogs == null) return Ok(Array.Empty()); - - var ids = _playerLogs.GetLoggedClientIds(); - var result = ids.Select(id => new - { - id, - name = _playerLogs.GetLatestName(id) ?? $"Player#{id}", - friendCode = _playerLogs.GetLatestFriendCode(id) ?? "—", - }).ToList(); - return Ok(result); - } - - [HttpGet("/api/admin/player/logs")] - public IActionResult GetPlayerLogs([FromQuery] int? clientId, [FromQuery] int page = 1, [FromQuery] int pageSize = 100) - { - if (!IsAuthenticated()) return Unauthorized(); - if (_playerLogs == null) return Ok(new { page = 1, pageSize = 0, total = 0, totalPages = 0, entries = Array.Empty() }); - - pageSize = Math.Clamp(pageSize, 1, 500); - var total = clientId.HasValue ? _playerLogs.GetCountByClient(clientId.Value) : _playerLogs.GetCount(); - var totalPages = (int)Math.Ceiling((double)total / pageSize); - var entries = clientId.HasValue - ? _playerLogs.GetPageByClient(clientId.Value, page, pageSize) - : _playerLogs.GetPage(page, pageSize); - - return Ok(new - { - page, - pageSize, - total, - totalPages, - entries = entries.Select(e => new - { - time = e.Time.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"), - type = e.Type, - playerName = e.PlayerName ?? "—", - friendCode = e.FriendCode ?? "—", - gameCode = e.GameCode ?? "—", - detail = e.Detail ?? "", - }).ToList(), - }); - } - - [HttpGet("/api/admin/player/logs/export")] - public IActionResult ExportPlayerLogs([FromQuery] int? clientId) - { - if (!IsAuthenticated()) return Unauthorized(); - if (_playerLogs == null) return File(Array.Empty(), "application/json", "player_logs.json"); - - var bytes = clientId.HasValue ? _playerLogs.ExportJson(clientId.Value) : _playerLogs.ExportJson(); - return File(bytes, "application/json", "player_logs.json"); - } - - [HttpPost("/api/admin/player/logs/clear")] - public IActionResult ClearPlayerLogs([FromQuery] string? olderThan) - { - if (!IsAuthenticated()) return Unauthorized(); - if (_playerLogs == null) return Ok(new { cleared = true }); - - DateTime? cutoff = olderThan?.ToLowerInvariant() switch - { - "1h" => DateTime.UtcNow.AddHours(-1), - "24h" => DateTime.UtcNow.AddDays(-1), - "7d" => DateTime.UtcNow.AddDays(-7), - "30d" => DateTime.UtcNow.AddDays(-30), - _ => null, - }; - - _playerLogs.Clear(cutoff); - return Ok(new { cleared = true }); - } - private IGame? FindGame(string code) { try { return _gameManager.Find(new GameCode(code.ToUpperInvariant())); } diff --git a/src/Empostor.Server/Http/AdminTemplateDefaults.cs b/src/Empostor.Server/Http/AdminTemplateDefaults.cs index 21be5fe..d4b78b3 100644 --- a/src/Empostor.Server/Http/AdminTemplateDefaults.cs +++ b/src/Empostor.Server/Http/AdminTemplateDefaults.cs @@ -854,7 +854,6 @@ internal static class AdminTemplateDefaults
Overview
Games
Clients
-
Player Logs
HPLP
Actions
@@ -1074,7 +1073,10 @@ internal static class AdminTemplateDefaults
-

Plugin Marketplace

+

Marketplace

+ + +
Loading...
@@ -1083,10 +1085,26 @@ internal static class AdminTemplateDefaults

Server Update Check

+
+ + +
+
Click Check to query GitHub.
- +
+ + +
+
@@ -1116,43 +1134,6 @@ internal static class AdminTemplateDefaults

Server Info

-
-
-

Player Logs

- - - - - - -
- - - - - - - - - - - - - -
TimeTypePlayerGameDetail
Loading...
-
-
-

HPLP (Public Lobby List)

@@ -1200,7 +1181,9 @@ internal static class AdminTemplateDefaults event.currentTarget.classList.add('active'); document.querySelectorAll('.pnl').forEach(e => e.classList.remove('active')); document.getElementById('p-' + id).classList.add('active'); - cur = id; refreshTab(); + cur = id; + if (id === 'mk') { fMarket(); } + refreshTab(); } async function api(m, p, b) { const o = { method: m, headers: { 'Content-Type': 'application/json' } }; @@ -1218,6 +1201,82 @@ internal static class AdminTemplateDefaults function e(s) { return String(s ?? '').replace(/&/g, '&').replace(//g, '>'); } function sc(s) { return { Started: 'bs', NotStarted: 'bn', Starting: 'by', Ended: 'be' }[s] || 'bn'; } + // ---- Preserve interaction state (text selection, resized controls) across refreshes ---- + function nodePath(root, node) { + const path = []; + let n = node; + while (n && n !== root) { + const p = n.parentNode; + if (!p) return null; + path.unshift(Array.prototype.indexOf.call(p.childNodes, n)); + n = p; + } + return n === root ? path : null; + } + function nodeAt(root, path) { + let n = root; + for (let i = 0; i < path.length && n; i++) n = n.childNodes[path[i]]; + return n; + } + function captureUI(root) { + const snap = { fields: [], ranges: [], scroll: root.scrollTop }; + root.querySelectorAll('input,textarea,select').forEach(el => { + snap.fields.push({ + w: el.style.width, h: el.style.height, + ss: el.selectionStart, se: el.selectionEnd + }); + }); + const sel = window.getSelection(); + if (sel && !sel.isCollapsed) { + for (let i = 0; i < sel.rangeCount; i++) { + const r = sel.getRangeAt(i); + if (!root.contains(r.startContainer) || !root.contains(r.endContainer)) continue; + const sp = nodePath(root, r.startContainer), ep = nodePath(root, r.endContainer); + if (sp && ep) snap.ranges.push({ sp, so: r.startOffset, ep, eo: r.endOffset }); + } + } + return snap; + } + function restoreUI(root, snap) { + if (!snap) return; + const els = root.querySelectorAll('input,textarea,select'); + if (els.length === snap.fields.length) { + snap.fields.forEach((f, i) => { + const el = els[i]; + if (f.w) el.style.width = f.w; + if (f.h) el.style.height = f.h; + if (el === document.activeElement && el.setSelectionRange + && typeof f.ss === 'number' && f.ss >= 0 && f.ss !== f.se) { + try { el.setSelectionRange(f.ss, f.se); } catch (err) { } + } + }); + } + if (snap.scroll) root.scrollTop = snap.scroll; + if (snap.ranges.length) { + const sel = window.getSelection(); + sel.removeAllRanges(); + snap.ranges.forEach(rg => { + const s = nodeAt(root, rg.sp), en = nodeAt(root, rg.ep); + if (!s || !en) return; + try { + const r = document.createRange(); + r.setStart(s, rg.so); + r.setEnd(en, rg.eo); + sel.addRange(r); + } catch (err) { } + }); + } + } + // Re-render only when the markup really changed: an unchanged 1s refresh must + // not touch the DOM, otherwise selections and resized controls get reset. + function setHTML(el, html) { + if (!el || el._wbHtml === html) return; + const snap = captureUI(el); + el._wbHtml = html; + el.innerHTML = html; + restoreUI(el, snap); + } + async function fetchStatus() { try { const { data: d } = await api('GET', '/api/admin/status'); @@ -1230,14 +1289,14 @@ internal static class AdminTemplateDefaults document.getElementById('s5').textContent = d.uptime; document.getElementById('upd').textContent = 'Updated ' + new Date().toLocaleTimeString(); document.getElementById('dot').style.background = 'var(--g)'; - document.getElementById('si-d').innerHTML = [ + setHTML(document.getElementById('si-d'), [ [_('serverinfo.started', 'Started'), d.startTime], [_('serverinfo.uptime', 'Uptime'), d.uptime], [_('serverinfo.pid', 'PID'), d.pid], [_('serverinfo.runtime', 'Runtime'), d.runtime], [_('serverinfo.os', 'OS'), d.os], [_('status.bans', 'Bans'), d.bannedIps + ' IPs, ' + d.bannedFriendCodes + ' FCs'] - ].map(([k, v]) => `
${e(k)}
${e(v)}
`).join(''); + ].map(([k, v]) => `
${e(k)}
${e(v)}
`).join('')); } catch { document.getElementById('dot').style.background = 'var(--r)'; } } @@ -1246,42 +1305,42 @@ internal static class AdminTemplateDefaults const { data: gs } = await api('GET', url); const tb = document.getElementById(tid); if (!gs.length) { - tb.innerHTML = `${_('games.none', 'No games')}`; + setHTML(tb, `${_('games.none', 'No games')}`); return; } - tb.innerHTML = gs.map(g => `${e(g.code)}${e(g.state)}${g.isPublic ? _('games.public', 'Public') : _('games.private', 'Private')}${e(g.map)}${g.playerCount}/${g.maxPlayers}${e(g.host)}
${e(g.hostFc)}${short ? '' : '
' + g.players.map(p => `${e(p.name)}`).join('') + '
'}`).join(''); + setHTML(tb, gs.map(g => `${e(g.code)}${e(g.state)}${g.isPublic ? _('games.public', 'Public') : _('games.private', 'Private')}${e(g.map)}${g.playerCount}/${g.maxPlayers}${e(g.host)}
${e(g.hostFc)}${short ? '' : '
' + g.players.map(p => `${e(p.name)}`).join('') + '
'}`).join('')); } async function fClients() { const { data: cs } = await api('GET', '/api/admin/clients'); const tb = document.getElementById('cl-t'); if (!cs.length) { - tb.innerHTML = '' + _('clients.none', 'No clients') + ''; + setHTML(tb, '' + _('clients.none', 'No clients') + ''); return; } - tb.innerHTML = cs.map(c => { + setHTML(tb, cs.map(c => { let modsHtml = '—'; if (c.reactor && c.reactor.mods && c.reactor.mods.length) { modsHtml = `${c.reactor.mods.length} ${_('clients.mod_count', 'mod(s)')}`; } return `${c.id}${e(c.name)}${e(c.friendCode)}${e(c.ip)}${e(c.gameVersion)}${e(c.platform)}${modsHtml}${c.inGame ? `${e(c.gameCode)}` : '' + _('clients.lobby', 'Lobby') + ''}`; - }).join(''); + }).join('')); } async function fKickList() { const { data: cs } = await api('GET', '/api/admin/clients'); const tb = document.getElementById('ki-t'); if (!cs.length) { - tb.innerHTML = '' + _('clients.none', 'No clients') + ''; + setHTML(tb, '' + _('clients.none', 'No clients') + ''); return; } - tb.innerHTML = cs.map(c => `${c.id}${e(c.name)}${e(c.friendCode)}${c.inGame ? `${e(c.gameCode)}` : '—'}`).join(''); + setHTML(tb, cs.map(c => `${c.id}${e(c.name)}${e(c.friendCode)}${c.inGame ? `${e(c.gameCode)}` : '—'}`).join('')); } async function fBans() { const { data: d } = await api('GET', '/api/admin/bans'); - document.getElementById('bl-ip').innerHTML = d.ips.length ? d.ips.map(b => bi(b, 'ip')).join('') : '
' + _('ban.none', 'None') + '
'; - document.getElementById('bl-fc').innerHTML = d.friendCodes.length ? d.friendCodes.map(b => bi(b, 'fc')).join('') : '
' + _('ban.none', 'None') + '
'; + setHTML(document.getElementById('bl-ip'), d.ips.length ? d.ips.map(b => bi(b, 'ip')).join('') : '
' + _('ban.none', 'None') + '
'); + setHTML(document.getElementById('bl-fc'), d.friendCodes.length ? d.friendCodes.map(b => bi(b, 'fc')).join('') : '
' + _('ban.none', 'None') + '
'); } function bi(b, t) { const until = b.bannedUntil ? new Date(b.bannedUntil).toLocaleString() : _('ban.permanent', 'Permanent'); @@ -1292,13 +1351,89 @@ internal static class AdminTemplateDefaults const { data: gs } = await api('GET', '/api/admin/games'); const tb = document.getElementById('ge-t'); if (!gs.length) { - tb.innerHTML = '' + _('games.none', 'No games') + ''; + setHTML(tb, '' + _('games.none', 'No games') + ''); return; } - tb.innerHTML = gs.map(g => `${e(g.code)}${e(g.state)}${g.playerCount}/${g.maxPlayers}`).join(''); + setHTML(tb, gs.map(g => `${e(g.code)}${e(g.state)}${g.playerCount}/${g.maxPlayers}`).join('')); } + let _mkTab = 'plugins'; + function mkTab(name) { + _mkTab = name; + document.getElementById('mk-tab-plugins').classList.toggle('bp', name === 'plugins'); + document.getElementById('mk-tab-themes').classList.toggle('bp', name === 'themes'); + fMarket(); + } async function fMarket() { + return _mkTab === 'themes' ? fMarketThemes() : fMarketPlugins(); + } + + // ---- Theme market: every theme the panel can offer, with a palette preview ---- + async function fMarketThemes() { + const el = document.getElementById('mk-list'); + el.innerHTML = '
' + _('marketplace.loading', 'Loading...') + '
'; + const { ok, data } = await api('GET', '/api/admin/marketplace/themes'); + if (!ok) { el.innerHTML = `
${e(data.error ?? 'Error')}
`; return; } + if (!data.length) { el.innerHTML = '
' + _('marketplace.no_themes', 'No themes.') + '
'; return; } + const active = document.getElementById('theme-sel').value; + el.innerHTML = '
' + data.map(t => { + const swatch = themeSwatch(t); + const badge = t.installed + ? (t.id === active + ? '' + _('marketplace.active', 'Active') + '' + : '' + _('marketplace.installed', 'Installed') + '') + : '' + _('marketplace.remote', 'Not installed') + ''; + const action = t.installed + ? `` + : ``; + const src = t.source === 'remote' + ? _('marketplace.source_remote', 'remote') + : t.source; + return `
+
+
${e(t.name)}
${badge} +
+ ${swatch} +
${e(t.description || '')}
+
+ ${action} + ${e(src)}${t.author ? ' · ' + e(t.author) : ''} +
+
`; + }).join('') + '
'; + } + + function themeSwatch(t) { + const dark = document.documentElement.getAttribute('data-theme') === 'dark'; + const tokens = (dark && t.darkTokens && Object.keys(t.darkTokens).length) ? t.darkTokens : t.tokens; + // "default" ships no tokens of its own; show the built-in palette instead of blanks. + const fallback = dark + ? { '--bg': '#0d1117', '--s': '#161b22', '--a': '#2f81f7', '--g': '#3fb950', '--t': '#e6edf3' } + : { '--bg': '#ffffff', '--s': '#f6f8fa', '--a': '#0969da', '--g': '#1a7f37', '--t': '#1f2328' }; + const src = (tokens && Object.keys(tokens).length) ? tokens : fallback; + const keys = ['--bg', '--s', '--a', '--g', '--t']; + return '
' + keys.map(k => + `` + ).join('') + '
'; + } + + async function applyTheme(id) { + await selectTheme(id); + await initThemes(); + const sel = document.getElementById('theme-sel'); + if (sel) sel.value = id; + if (_mkTab === 'themes') fMarketThemes(); + } + + async function installTheme(id, url) { + if (!url) return; + const { ok, data } = await api('POST', '/api/admin/marketplace/themes/install', { id, downloadUrl: url }); + if (!ok) { alert(data.error ?? 'Error'); return; } + await initThemes(); + fMarketThemes(); + } + + async function fMarketPlugins() { const el = document.getElementById('mk-list'); el.innerHTML = '
' + _('marketplace.loading', 'Loading...') + '
'; try { @@ -1360,115 +1495,115 @@ internal static class AdminTemplateDefaults } } + let _udReleases = null; + + function udFormatDate(v) { + if (!v) return '—'; + const d = new Date(v); + return isNaN(d.getTime()) ? v : d.toLocaleString(); + } + + async function loadUpdateReleases() { + const sel = document.getElementById('ud-tag'); + if (_udReleases) return _udReleases; + sel.innerHTML = ''; + const { ok, data } = await api('GET', '/api/admin/update/releases'); + if (!ok || !Array.isArray(data)) { + sel.innerHTML = ''; + return null; + } + _udReleases = data; + sel.innerHTML = data.map(r => { + const mark = r.prerelease ? ' [' + _('updates.prerelease', 'prerelease') + ']' : ''; + return ``; + }).join(''); + return data; + } + + async function onChannelChange() { + const ch = document.getElementById('ud-ch').value; + document.getElementById('ud-tag-field').style.display = ch === 'tag' ? '' : 'none'; + if (ch === 'tag') await loadUpdateReleases(); + await fUpdate(); + } + async function fUpdate() { const box = document.getElementById('ud-box'); - box.innerHTML = '
' + _('updates.checking', 'Checking...') + '
'; - const { ok, data } = await api('GET', '/api/admin/update/check'); - if (!ok) { box.innerHTML = `
${e(data.error ?? 'Error')}
`; return; } + const channel = document.getElementById('ud-ch').value; + const tag = channel === 'tag' ? document.getElementById('ud-tag').value : ''; + if (channel === 'tag' && !tag) { + setHTML(box, '
' + _('updates.select_version_hint', 'Select a version to check.') + '
'); + return; + } + setHTML(box, '
' + _('updates.checking', 'Checking...') + '
'); + const q = '/api/admin/update/check?channel=' + encodeURIComponent(channel) + + (tag ? '&tag=' + encodeURIComponent(tag) : ''); + const { ok, data } = await api('GET', q); + if (!ok) { + setHTML(box, updateErrorHtml(data)); + document.getElementById('ud-dl').style.display = 'none'; + return; + } const badge = data.upToDate ? '' + _('updates.up_to_date', 'Up to date') + '' : '' + _('updates.update_available', 'Update available') + ''; - box.innerHTML = `
-
${_('updates.current', 'Current')}
${e(data.currentVersion)}
+ const cur = data.currentVersion; + const channelLabel = { + stable: _('updates.channel_stable', 'Stable release'), + nightly: _('updates.channel_nightly', 'Nightly build'), + tag: _('updates.channel_tag', 'Specific version') + }[data.channel] || data.channel; + const pre = data.isPrerelease + ? ' ' + _('updates.prerelease', 'prerelease') + '' : ''; + const platform = data.platformAsset + ? e(data.platform) + ' · ' + e(data.platformAsset) + : e(data.platform) + ' — ' + _('updates.no_platform_asset', 'no package published for this platform'); + const dl = document.getElementById('ud-dl'); + dl.style.display = data.platformAsset ? '' : 'none'; + dl.title = data.platformAsset || ''; + document.getElementById('ud-dl-result').style.display = 'none'; + setHTML(box, `
+
${_('updates.channel', 'Channel')}
${e(channelLabel)}${pre}
+
${_('updates.current', 'Current')}
${e(cur)}
${_('updates.latest', 'Latest')}
${e(data.latestVersion)} ${badge}
+
${_('updates.tag', 'Tag')}
${e(data.latestTag)}
${_('updates.release', 'Release')}
-
${!data.upToDate ? '

' + _('updates.update_hint', 'A new version is available. Update manually.') + '

' : ''}`; +
${_('updates.published', 'Published')}
${e(udFormatDate(data.publishedAt))}
+
${_('updates.platform', 'Package')}
${platform}
+
${!data.upToDate ? '

' + _('updates.update_hint', 'A new version is available. Update manually.') + '

' : ''}`); } - let plPage = 1; - const plPageSize = 100; - - async function fPlayerLogs(page) { - if (page !== undefined) plPage = page; - const sel = document.getElementById('pl-client'); - const curVal = sel.value; - const typeSel = document.getElementById('pl-type'); - const curType = typeSel.value; - const tb = document.getElementById('pl-t'); - - if (!sel.hasAttribute('data-loaded')) { - try { - const { data: cls } = await api('GET', '/api/admin/player/logs/clients'); - sel.innerHTML = ''; - cls.forEach(c => { sel.innerHTML += ``; }); - sel.value = curVal; - sel.setAttribute('data-loaded', '1'); - } catch { } - } - - if (!typeSel.hasAttribute('data-loaded')) { - const types = ['Chat','Report','Murder','Exile','Vote','Task','Vent','Meeting','Connect','Game','Join','Leave']; - types.forEach(t => { typeSel.innerHTML += ``; }); - typeSel.value = curType; - typeSel.setAttribute('data-loaded', '1'); - } - - let url = sel.value - ? `/api/admin/player/logs?clientId=${sel.value}&page=${plPage}&pageSize=${plPageSize}` - : `/api/admin/player/logs?page=${plPage}&pageSize=${plPageSize}`; - const { data } = await api('GET', url); - let items = data.entries || []; - if (curType) items = items.filter(l => l.type === curType); - - if (!items.length) { - tb.innerHTML = '' + _('player_logs.no_logs', 'No logs.') + ''; - document.getElementById('pl-pager').innerHTML = ''; - return; - } - - const typeColor = { Chat: 'var(--a)', Report: 'var(--r)', Murder: 'var(--r)', Exile: 'var(--o)', Vote: 'var(--y)', Task: 'var(--g)', Vent: 'var(--p)', Meeting: 'var(--m)', Connect: 'var(--g)', Game: 'var(--m)', Join: 'var(--g)', Leave: 'var(--o)' }; - tb.innerHTML = items.map(l => ` - ${e(l.time)} - ${e(l.type)} - ${e(l.playerName)}
${e(l.friendCode)} - ${l.gameCode && l.gameCode !== '—' ? '' + e(l.gameCode) + '' : '—'} - ${e(l.detail)} - `).join(''); - - renderPlPager(data.totalPages, data.total); + // Rate limiting is a configuration problem, not a crash: show it in amber with the reset time. + function updateErrorHtml(data) { + const limited = data && data.rateLimited; + const reset = limited && data.resetAt + ? '
' + _('updates.rate_limit_reset', 'Rate limit resets at ') + e(data.resetAt) + : ''; + const tone = limited ? 'var(--y)' : 'var(--r)'; + return `
${e((data && data.error) || 'Error')}${reset}
`; } - function renderPlPager(totalPages, total) { - const pg = document.getElementById('pl-pager'); - if (totalPages <= 1) { pg.innerHTML = ''; return; } - let h = `${total} 条`; - if (plPage > 1) h += ``; - const range = 5; - let start = Math.max(1, plPage - range); - let end = Math.min(totalPages, plPage + range); - if (start > 1) h += ``; - if (start > 2) h += `…`; - for (let i = start; i <= end; i++) { - h += ``; - } - if (end < totalPages - 1) h += `…`; - if (end < totalPages) h += ``; - if (plPage < totalPages) h += ``; - pg.innerHTML = h; - } - - function exportLogs() { - const sel = document.getElementById('pl-client'); - const url = sel.value ? `/api/admin/player/logs/export?clientId=${sel.value}` : '/api/admin/player/logs/export'; - window.open(url, '_blank'); - } - - async function clearLogs() { - const range = document.getElementById('pl-clear-range').value; - const msgKey = range === 'all' - ? _('player_logs.confirm_clear_all', 'Clear all player logs? This cannot be undone.') - : _('player_logs.confirm_clear_range', 'Clear logs older than the selected period? This cannot be undone.'); - if (!confirm(msgKey)) return; - const { ok, data } = await api('POST', `/api/admin/player/logs/clear?olderThan=${encodeURIComponent(range)}`); - msg('bi-msg', ok, ok ? _('player_logs.cleared', 'Player logs cleared.') : (data.error ?? 'Error')); - if (!ok) return; - // Reset the client dropdown cache and table so the cleared state is - // reflected immediately (no stale player list / logs remain). - const sel = document.getElementById('pl-client'); - sel.removeAttribute('data-loaded'); - sel.value = ''; - document.getElementById('pl-type').value = ''; - fPlayerLogs(); + // Downloads into Update/{version}/{asset name} on the server, never touching the running install. + async function fDownload() { + const btn = document.getElementById('ud-dl'); + const label = btn.querySelector('span'); + const result = document.getElementById('ud-dl-result'); + const channel = document.getElementById('ud-ch').value; + const tag = channel === 'tag' ? document.getElementById('ud-tag').value : ''; + if (channel === 'tag' && !tag) return; + const original = label.textContent; + btn.disabled = true; + label.textContent = _('updates.downloading', 'Downloading...'); + const { ok, data } = await api('POST', '/api/admin/update/download', { channel, tag }); + btn.disabled = false; + label.textContent = original; + result.className = 'msg ' + (ok ? 'ok' : 'err'); + result.textContent = ok + ? (data.alreadyDownloaded + ? _('updates.download_exists', 'Already downloaded: ') + : _('updates.download_saved', 'Saved to ')) + data.relativePath + : (data.error ?? 'Error'); + result.style.display = 'block'; } async function fHplp() { @@ -1509,7 +1644,6 @@ internal static class AdminTemplateDefaults if (cur === 'ki') fKickList(); if (cur === 'bl') fBans(); if (cur === 'ge') fGamesEnd(); - if (cur === 'pl') fPlayerLogs(); if (cur === 'hp') fHplp(); if (cur.startsWith('ext-')) loadExtension(cur.slice(4)); } @@ -1720,8 +1854,8 @@ internal static class AdminTemplateDefaults const ae = document.activeElement; if (ae && body.contains(ae) && /^(INPUT|TEXTAREA|SELECT)$/.test(ae.tagName)) return; const { ok, data } = await api('GET', '/api/admin/ext/' + encodeURIComponent(id)); - if (!ok) { body.innerHTML = '
' + (data && data.error ? e(data.error) : 'Error') + '
'; return; } - body.innerHTML = (data.widgets || []).map(renderWidget).join(''); + if (!ok) { setHTML(body, '
' + (data && data.error ? e(data.error) : 'Error') + '
'); return; } + setHTML(body, (data.widgets || []).map(renderWidget).join('')); } function toast(ok, text) { let t = document.getElementById('ext-toast'); @@ -1829,6 +1963,7 @@ internal static class AdminTemplateDefaults _themeDefault = data.default || 'default'; _modeDefault = data.defaultMode === 'light' ? 'light' : 'dark'; const sel = document.getElementById('theme-sel'); + sel.innerHTML = ''; (data.themes || []).forEach(function (t) { const o = document.createElement('option'); o.value = t.id; diff --git a/src/Empostor.Server/Http/MarketplaceController.cs b/src/Empostor.Server/Http/MarketplaceController.cs index d289bbb..9fa7bd4 100644 --- a/src/Empostor.Server/Http/MarketplaceController.cs +++ b/src/Empostor.Server/Http/MarketplaceController.cs @@ -4,8 +4,10 @@ using System.IO; using System.Linq; using System.Net.Http; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading.Tasks; using Empostor.Api.Config; +using Empostor.Server.Http.Admin; using Empostor.Server.Plugins; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -19,30 +21,31 @@ namespace Empostor.Server.Http private static readonly string PluginsDir = Path.Combine(Directory.GetCurrentDirectory(), "plugins"); - private const string EmpostorRepo = "Empostor/Empostor"; + private static readonly Regex ThemeIdPattern = new("^[a-z0-9][a-z0-9-]{1,40}$", RegexOptions.Compiled); private readonly ILogger _logger; private readonly IHttpClientFactory _http; private readonly AdminConfig _config; private readonly PluginLoaderService _pluginLoaderService; + private readonly AdminThemeRegistry _themes; private readonly string _passwordHash; public MarketplaceController( ILogger logger, IHttpClientFactory http, IOptions config, - PluginLoaderService pluginLoaderService) + PluginLoaderService pluginLoaderService, + AdminThemeRegistry themes) { _logger = logger; _http = http; _config = config.Value; _pluginLoaderService = pluginLoaderService; + _themes = themes; _passwordHash = AdminController.ComputeHash(_config.Password); } - private bool IsAuthenticated() - => Request.Cookies.TryGetValue("empostor_admin", out var v) - && AdminController.ConstantTimeEquals(v, _passwordHash); + private bool IsAuthenticated() => AdminSession.IsAuthenticated(HttpContext, _passwordHash); [HttpGet("/api/admin/marketplace/plugins")] public async Task ListPlugins() @@ -60,8 +63,7 @@ namespace Empostor.Server.Http try { - using var client = _http.CreateClient(); - client.DefaultRequestHeaders.Add("User-Agent", "Empostor-Marketplace/1.0"); + using var client = CreateClient(); var json = await client.GetStringAsync(url); var installedIds = new HashSet( @@ -135,8 +137,7 @@ namespace Empostor.Server.Http { Directory.CreateDirectory(PluginsDir); - using var client = _http.CreateClient(); - client.DefaultRequestHeaders.Add("User-Agent", "Empostor-Marketplace/1.0"); + using var client = CreateClient(); client.Timeout = TimeSpan.FromSeconds(60); var fileName = Path.GetFileName(new Uri(req.DownloadUrl).LocalPath); @@ -158,38 +159,173 @@ namespace Empostor.Server.Http } } - [HttpGet("/api/admin/update/check")] - public async Task CheckUpdate() + [HttpGet("/api/admin/marketplace/themes")] + public async Task ListThemes() { if (!IsAuthenticated()) { return Unauthorized(); } - var current = Utils.DotnetUtils.Version; + _themes.Rescan(); + + var remote = new List(); + var url = _config.ThemeMarketplaceUrl; + if (!string.IsNullOrWhiteSpace(url)) + { + try + { + using var client = CreateClient(); + var json = await client.GetStringAsync(url); + remote = JsonSerializer.Deserialize>( + json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? new List(); + } + catch (Exception ex) + { + // A missing or unreachable catalogue must not hide the local themes. + _logger.LogWarning(ex, "MarketplaceFailed to fetch themes from {Url}", url); + } + } + + var themes = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in _themes.Themes) + { + if (!seen.Add(entry.Id)) + { + continue; + } + + var info = remote.FirstOrDefault(r => string.Equals(r.Id, entry.Id, StringComparison.OrdinalIgnoreCase)); + themes.Add(new + { + id = entry.Id, + name = entry.Name, + source = entry.Source, + installed = true, + description = string.IsNullOrWhiteSpace(info?.Description) ? DefaultDescription(entry.Source) : info!.Description, + author = info?.Author, + downloadUrl = info?.DownloadUrl, + tokens = _themes.Resolve(entry.Id)?.Tokens, + darkTokens = _themes.Resolve(entry.Id)?.DarkTokens, + }); + } + + foreach (var entry in remote) + { + if (string.IsNullOrWhiteSpace(entry.Id) || !seen.Add(entry.Id)) + { + continue; + } + + themes.Add(new + { + id = entry.Id, + name = string.IsNullOrWhiteSpace(entry.Name) ? entry.Id : entry.Name, + source = "remote", + installed = false, + description = entry.Description, + author = entry.Author, + downloadUrl = entry.DownloadUrl, + tokens = (Dictionary?)null, + darkTokens = (Dictionary?)null, + }); + } + + return Ok(themes); + } + + /// + /// Writes Pages/themes/{Id}/theme.json. The registry is rescanned afterwards, so the + /// theme is switchable immediately without restarting the server. + /// + [HttpPost("/api/admin/marketplace/themes/install")] + public async Task InstallTheme([FromBody] ThemeInstallRequest req) + { + if (!IsAuthenticated()) + { + return Unauthorized(); + } + + var id = (req.Id ?? string.Empty).Trim().ToLowerInvariant(); + if (!ThemeIdPattern.IsMatch(id)) + { + return BadRequest(new { error = "A theme id of lowercase letters, digits and dashes is required." }); + } + + if (string.IsNullOrWhiteSpace(req.DownloadUrl) + || !req.DownloadUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return BadRequest(new { error = "Only HTTPS download URLs are allowed" }); + } + try { - using var client = _http.CreateClient(); - client.DefaultRequestHeaders.Add("User-Agent", "Empostor-UpdateCheck/1.0"); - var json = await client.GetStringAsync( - $"https://api.github.com/repos/{EmpostorRepo}/releases/latest"); + using var client = CreateClient(); + var json = await client.GetStringAsync(req.DownloadUrl); + + // Validate before touching disk: it must parse and declare the id we are installing. using var doc = JsonDocument.Parse(json); - var tag = doc.RootElement.GetProperty("tag_name").GetString() ?? string.Empty; - var url = doc.RootElement.GetProperty("html_url").GetString() ?? string.Empty; - var name = doc.RootElement.GetProperty("name").GetString() ?? tag; - var latest = tag.TrimStart('v'); - var isCurrent = string.Equals( - current.Split('+')[0], latest, StringComparison.OrdinalIgnoreCase); - return Ok(new { currentVersion = current, latestVersion = latest, latestTag = tag, latestName = name, releaseUrl = url, upToDate = isCurrent }); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + return BadRequest(new { error = "The downloaded file is not a theme.json object." }); + } + + var declared = ReadId(doc.RootElement); + if (!string.Equals(declared, id, StringComparison.OrdinalIgnoreCase)) + { + return BadRequest(new { error = $"theme.json declares id '{declared}', expected '{id}'." }); + } + + var directory = Path.Combine(AdminThemeRegistry.ThemesDirectory, id); + Directory.CreateDirectory(directory); + await System.IO.File.WriteAllTextAsync(Path.Combine(directory, "theme.json"), json); + + _themes.Rescan(); + _logger.LogInformation("MarketplaceInstalled theme {Id} from {Url}", id, req.DownloadUrl); + return Ok(new { installed = id, restartRequired = false }); } catch (Exception ex) { - _logger.LogWarning(ex, "UpdateGitHub check failed"); + _logger.LogWarning(ex, "MarketplaceInstall theme failed: {Url}", req.DownloadUrl); return StatusCode(502, new { error = ex.Message }); } } - public sealed record InstallRequest(string DownloadUrl, string? PluginId = null); + private static string DefaultDescription(string source) => source switch + { + "builtin" => "The stock look of the admin panel.", + "plugin" => "Shipped by an installed plugin.", + _ => "Installed on this server.", + }; + + private static string? ReadId(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + foreach (var name in new[] { "Id", "id" }) + { + if (element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String) + { + return value.GetString(); + } + } + + return null; + } + + private HttpClient CreateClient() + { + var client = _http.CreateClient(); + client.DefaultRequestHeaders.Add("User-Agent", "Empostor-Marketplace/1.0"); + return client; + } private static object? JsonValueToObject(JsonElement element) { @@ -205,5 +341,22 @@ namespace Empostor.Server.Http _ => null, }; } + + public sealed record InstallRequest(string DownloadUrl, string? PluginId = null); + + public sealed record ThemeInstallRequest(string Id, string DownloadUrl); + + private sealed class RemoteTheme + { + public string? Id { get; set; } + + public string? Name { get; set; } + + public string? Description { get; set; } + + public string? Author { get; set; } + + public string? DownloadUrl { get; set; } + } } } diff --git a/src/Empostor.Server/Http/UpdateController.cs b/src/Empostor.Server/Http/UpdateController.cs new file mode 100644 index 0000000..a1f5068 --- /dev/null +++ b/src/Empostor.Server/Http/UpdateController.cs @@ -0,0 +1,473 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Empostor.Api.Config; +using Empostor.Server.Http.Admin; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Empostor.Server.Http +{ + /// + /// Server-version endpoints for the admin panel's Updates tab: querying GitHub releases + /// (stable / nightly / an explicit tag), listing them for manual selection, and downloading + /// the package that matches this machine. Updating the running installation stays manual. + /// + [ApiController] + public sealed class UpdateController : ControllerBase + { + private const string EmpostorRepo = "Empostor/Empostor"; + + private const string ChannelStable = "stable"; + private const string ChannelNightly = "nightly"; + private const string ChannelTag = "tag"; + + /// Packages downloaded from the update panel land in Update/{Version}/. + private const string UpdateRootDirName = "Update"; + + private static readonly string[] SupportedRids = { "win-x64", "linux-x64", "linux-arm", "linux-arm64", "osx-x64" }; + + private static readonly Regex TagNamePattern = new("^[A-Za-z0-9._-]{1,64}$", RegexOptions.Compiled); + + private static readonly Regex VersionInName = new(@"\(([^)]+)\)", RegexOptions.Compiled); + + /// + /// Successful lookups are cached for a minute so repeated clicks cannot burn the API quota; + /// GitHub only allows 60 anonymous requests per hour per IP. + /// + private static readonly ConcurrentDictionary ResponseCache = new(); + + private static readonly TimeSpan CacheLifetime = TimeSpan.FromSeconds(60); + + private readonly ILogger _logger; + private readonly IHttpClientFactory _http; + private readonly string _passwordHash; + private readonly string _gitHubToken; + + public UpdateController( + ILogger logger, + IHttpClientFactory http, + IOptions config) + { + _logger = logger; + _http = http; + _passwordHash = AdminController.ComputeHash(config.Value.Password); + _gitHubToken = (config.Value.GitHubToken ?? string.Empty).Trim(); + } + + private bool IsAuthenticated() => AdminSession.IsAuthenticated(HttpContext, _passwordHash); + + [HttpGet("/api/admin/update/check")] + public async Task CheckUpdate([FromQuery] string? channel = null, [FromQuery] string? tag = null) + { + if (!IsAuthenticated()) + { + return Unauthorized(); + } + + var (mode, target, channelError) = ResolveChannel(channel, tag); + if (channelError != null) + { + return BadRequest(new { error = channelError }); + } + + var current = Utils.DotnetUtils.Version; + try + { + using var client = CreateClient(); + var fetch = await FetchAsync(client, ReleaseApiUrl(mode, target)); + if (!fetch.Ok) + { + return GitHubError(fetch); + } + + using var doc = JsonDocument.Parse(fetch.Json!); + var root = doc.RootElement; + var tagName = GetString(root, "tag_name"); + var name = GetString(root, "name"); + var url = GetString(root, "html_url"); + var latest = mode == ChannelStable ? tagName.TrimStart('v') : ExtractVersion(name, tagName); + var isCurrent = string.Equals( + current.Split('+')[0], latest, StringComparison.OrdinalIgnoreCase); + var rid = CurrentPlatformRid(); + var package = FindPlatformAsset(root, rid); + return Ok(new + { + channel = mode, + queryTag = mode == ChannelTag ? target : tagName, + currentVersion = current, + latestVersion = latest, + latestTag = tagName, + latestName = string.IsNullOrWhiteSpace(name) ? tagName : name, + releaseUrl = url, + isPrerelease = IsTrue(root, "prerelease"), + publishedAt = GetString(root, "published_at"), + upToDate = isCurrent, + platform = rid, + platformAsset = package?.Name, + platformAssetSize = package?.Size ?? 0, + }); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "UpdateGitHub check failed for channel {Channel}", mode); + return StatusCode(502, new { error = ex.Message }); + } + } + + [HttpGet("/api/admin/update/releases")] + public async Task ListReleases([FromQuery] int limit = 30) + { + if (!IsAuthenticated()) + { + return Unauthorized(); + } + + limit = Math.Clamp(limit, 1, 100); + try + { + using var client = CreateClient(); + var fetch = await FetchAsync( + client, $"https://api.github.com/repos/{EmpostorRepo}/releases?per_page={limit}"); + if (!fetch.Ok) + { + return GitHubError(fetch); + } + + using var doc = JsonDocument.Parse(fetch.Json!); + + var releases = new List(); + foreach (var element in doc.RootElement.EnumerateArray()) + { + if (IsTrue(element, "draft")) + { + continue; + } + + var tagName = GetString(element, "tag_name"); + var name = GetString(element, "name"); + releases.Add(new + { + tag = tagName, + name = string.IsNullOrWhiteSpace(name) ? tagName : name, + version = ExtractVersion(name, tagName), + prerelease = IsTrue(element, "prerelease"), + publishedAt = GetString(element, "published_at"), + releaseUrl = GetString(element, "html_url"), + }); + } + + return Ok(releases); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "UpdateGitHub release list failed"); + return StatusCode(502, new { error = ex.Message }); + } + } + + /// + /// Downloads the package matching this machine into Update/{version}/{original asset name}. + /// + [HttpPost("/api/admin/update/download")] + public async Task DownloadUpdate([FromBody] UpdateDownloadRequest req) + { + if (!IsAuthenticated()) + { + return Unauthorized(); + } + + var (mode, target, channelError) = ResolveChannel(req.Channel, req.Tag); + if (channelError != null) + { + return BadRequest(new { error = channelError }); + } + + try + { + using var client = CreateClient(); + var fetch = await FetchAsync(client, ReleaseApiUrl(mode, target)); + if (!fetch.Ok) + { + return GitHubError(fetch); + } + + using var doc = JsonDocument.Parse(fetch.Json!); + var root = doc.RootElement; + var tagName = GetString(root, "tag_name"); + var version = mode == ChannelStable + ? tagName.TrimStart('v') + : ExtractVersion(GetString(root, "name"), tagName); + var rid = CurrentPlatformRid(); + + var asset = FindPlatformAsset(root, rid); + if (asset == null) + { + return NotFound(new { error = $"Release '{tagName}' publishes no package for this platform ({rid})." }); + } + + var directory = Path.Combine( + Directory.GetCurrentDirectory(), UpdateRootDirName, SafePathSegment(version)); + Directory.CreateDirectory(directory); + var savePath = Path.Combine(directory, asset.Value.Name); + + if (!req.Force + && System.IO.File.Exists(savePath) + && new FileInfo(savePath).Length == asset.Value.Size + && asset.Value.Size > 0) + { + return Ok(BuildDownloadResult(version, rid, asset.Value, savePath, alreadyDownloaded: true)); + } + + // Asset downloads go to release-assets, not the API, so they need their own long timeout. + using var downloadClient = CreateClient(); + downloadClient.Timeout = TimeSpan.FromMinutes(10); + using (var response = await downloadClient.GetAsync(asset.Value.Url, HttpCompletionOption.ResponseHeadersRead)) + { + response.EnsureSuccessStatusCode(); + await using var source = await response.Content.ReadAsStreamAsync(); + await using var destination = System.IO.File.Create(savePath); + await source.CopyToAsync(destination); + } + + _logger.LogInformation("UpdateDownloaded {File} to {Path}", asset.Value.Name, savePath); + return Ok(BuildDownloadResult(version, rid, asset.Value, savePath, alreadyDownloaded: false)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "UpdateGitHub download failed"); + return StatusCode(502, new { error = ex.Message }); + } + } + + private HttpClient CreateClient() + { + var client = _http.CreateClient(); + client.DefaultRequestHeaders.Add("User-Agent", "Empostor-UpdateCheck/1.0"); + client.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json"); + client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28"); + if (_gitHubToken.Length > 0) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _gitHubToken); + } + + return client; + } + + /// + /// Fetches a GitHub API URL, caching successes and turning rate limiting into a structured + /// error instead of a bare that loses the response headers. + /// + private async Task FetchAsync(HttpClient client, string url) + { + if (ResponseCache.TryGetValue(url, out var cached) && cached.Expiry > DateTimeOffset.UtcNow) + { + return new GitHubResponse(cached.Json, null, false, null); + } + + using var response = await client.GetAsync(url); + if (response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + ResponseCache[url] = (DateTimeOffset.UtcNow.Add(CacheLifetime), body); + return new GitHubResponse(body, null, false, null); + } + + var status = (int)response.StatusCode; + var remaining = response.Headers.TryGetValues("X-RateLimit-Remaining", out var r) ? r.FirstOrDefault() : null; + var resetHeader = response.Headers.TryGetValues("X-RateLimit-Reset", out var s) ? s.FirstOrDefault() : null; + DateTimeOffset? resetAt = long.TryParse(resetHeader, out var epoch) + ? DateTimeOffset.FromUnixTimeSeconds(epoch) + : null; + + var rateLimited = status == 403 && remaining == "0"; + var detail = rateLimited + ? _gitHubToken.Length == 0 + ? "GitHub API rate limit exceeded: anonymous requests are capped at 60 per hour per IP. Set Admin.GitHubToken in config.json to raise it to 5000." + : "GitHub API rate limit exceeded for the configured token (5000 per hour)." + : $"GitHub API returned {status}."; + + _logger.LogWarning( + "UpdateGitHub {Url} failed: {Status}{Limited}", url, status, rateLimited ? " (rate limited)" : string.Empty); + return new GitHubResponse(null, detail, rateLimited, resetAt); + } + + private ObjectResult GitHubError(GitHubResponse response) => StatusCode(502, new + { + error = response.Error, + rateLimited = response.RateLimited, + resetAt = response.ResetAt?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"), + authenticated = _gitHubToken.Length > 0, + }); + + private sealed record GitHubResponse(string? Json, string? Error, bool RateLimited, DateTimeOffset? ResetAt) + { + public bool Ok => Json != null; + } + + private static (string Mode, string Target, string? Error) ResolveChannel(string? channel, string? tag) + { + var mode = string.IsNullOrWhiteSpace(channel) ? ChannelStable : channel.Trim().ToLowerInvariant(); + var target = (tag ?? string.Empty).Trim(); + + if (mode != ChannelStable && mode != ChannelNightly && mode != ChannelTag) + { + return (mode, target, $"Unknown release channel '{channel}'."); + } + + if (mode == ChannelTag && !TagNamePattern.IsMatch(target)) + { + return (mode, target, "A valid release tag is required."); + } + + return (mode, target, null); + } + + /// Portable RID of the running server, mapped onto the names build.cake publishes. + private static string CurrentPlatformRid() + { + var rid = RuntimeInformation.RuntimeIdentifier; + if (SupportedRids.Contains(rid, StringComparer.OrdinalIgnoreCase)) + { + return rid.ToLowerInvariant(); + } + + var arch = RuntimeInformation.ProcessArchitecture switch + { + Architecture.Arm64 => "arm64", + Architecture.Arm => "arm", + Architecture.X86 => "x86", + _ => "x64", + }; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "win-" + arch; + } + + return RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "osx-" + arch : "linux-" + arch; + } + + /// + /// Asset names always end with the RID — "Empostor-Server_2.0.0_win-x64.zip" for tagged + /// releases and "Empostor-Server-latest-win-x64.zip" for nightly — so a suffix match + /// covers both and skips unrelated files such as the nupkg or manifest.json. + /// + private static (string Name, string Url, long Size)? FindPlatformAsset(JsonElement release, string rid) + { + if (!release.TryGetProperty("assets", out var assets) || assets.ValueKind != JsonValueKind.Array) + { + return null; + } + + var candidates = new List<(string Name, string Url, long Size)>(); + foreach (var asset in assets.EnumerateArray()) + { + var name = GetString(asset, "name"); + if (!IsPlatformAsset(name, rid)) + { + continue; + } + + var size = asset.TryGetProperty("size", out var raw) && raw.TryGetInt64(out var parsed) ? parsed : 0; + candidates.Add((name, GetString(asset, "browser_download_url"), size)); + } + + // Windows ships .zip, the other platforms only .tar.gz; prefer the zip when both exist. + foreach (var candidate in candidates) + { + if (candidate.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + return candidate; + } + } + + return candidates.Count > 0 ? candidates[0] : null; + } + + /// Tagged releases use "_" before the RID, nightly uses "-"; accept both. + private static bool IsPlatformAsset(string name, string rid) + { + foreach (var separator in new[] { "-", "_" }) + { + if (name.EndsWith(separator + rid + ".zip", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(separator + rid + ".tar.gz", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// Keeps a version usable as a single directory name. + private static string SafePathSegment(string value) + { + var builder = new StringBuilder(value.Length); + foreach (var c in value) + { + builder.Append(char.IsLetterOrDigit(c) || c is '.' or '-' or '_' or '+' ? c : '_'); + } + + return builder.Length == 0 ? "unknown" : builder.ToString(); + } + + private static object BuildDownloadResult( + string version, string rid, (string Name, string Url, long Size) asset, string savePath, bool alreadyDownloaded) + { + var relative = Path.Combine(UpdateRootDirName, SafePathSegment(version), asset.Name).Replace('\\', '/'); + return new + { + version, + platform = rid, + fileName = asset.Name, + savedTo = savePath, + relativePath = relative, + size = System.IO.File.Exists(savePath) ? new FileInfo(savePath).Length : asset.Size, + alreadyDownloaded, + }; + } + + private static string ReleaseApiUrl(string mode, string tag) => mode switch + { + ChannelNightly => $"https://api.github.com/repos/{EmpostorRepo}/releases/tags/{ChannelNightly}", + ChannelTag => $"https://api.github.com/repos/{EmpostorRepo}/releases/tags/{Uri.EscapeDataString(tag)}", + _ => $"https://api.github.com/repos/{EmpostorRepo}/releases/latest", + }; + + /// Nightly builds carry their real version inside the release title, e.g. "Nightly build (2.1.0-ci.812)". + private static string ExtractVersion(string? name, string tag) + { + if (!string.IsNullOrWhiteSpace(name)) + { + var match = VersionInName.Match(name); + if (match.Success) + { + return match.Groups[1].Value.Trim(); + } + } + + return tag.TrimStart('v'); + } + + private static string GetString(JsonElement element, string property) + => element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() ?? string.Empty + : string.Empty; + + private static bool IsTrue(JsonElement element, string property) + => element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.True; + + public sealed record UpdateDownloadRequest(string? Channel = null, string? Tag = null, bool Force = false); + } +} diff --git a/src/Empostor.Server/Pages/AdminStrings.json b/src/Empostor.Server/Pages/AdminStrings.json index 7cfe6fa..16e5500 100644 --- a/src/Empostor.Server/Pages/AdminStrings.json +++ b/src/Empostor.Server/Pages/AdminStrings.json @@ -26,8 +26,8 @@ "nav.gamecontrol": "Game Control", "nav.extend": "Extend", "nav.system": "System", - "nav.player_logs": "Player Logs", "nav.hplp": "HPLP", + "nav.plugins": "Plugins", "nav.reload_lang": "Reload Lang", "status.games": "Games", @@ -108,7 +108,7 @@ "privacy.private": "Private", "privacy.apply": "Apply", - "marketplace.title": "Plugin Marketplace", + "marketplace.title": "Marketplace", "marketplace.refresh": "Refresh", "marketplace.install": "Install", "marketplace.installed": "Installed", @@ -116,6 +116,13 @@ "marketplace.restart": "Installed. Restart server to enable.", "marketplace.loading": "Loading...", "marketplace.no_plugins": "No plugins.", + "marketplace.tab_plugins": "Plugins", + "marketplace.tab_themes": "Themes", + "marketplace.apply": "Apply", + "marketplace.active": "Active", + "marketplace.remote": "Not installed", + "marketplace.source_remote": "remote", + "marketplace.no_themes": "No themes.", "updates.title": "Server Update Check", "updates.check": "Check for Updates", @@ -127,6 +134,23 @@ "updates.latest": "Latest", "updates.release": "Release", "updates.update_hint": "A new version is available. Update manually.", + "updates.channel": "Channel", + "updates.channel_stable": "Stable release", + "updates.channel_nightly": "Nightly build", + "updates.channel_tag": "Specific version", + "updates.select_version": "Version", + "updates.select_version_hint": "Select a version to check.", + "updates.loading_releases": "Loading releases...", + "updates.prerelease": "prerelease", + "updates.tag": "Tag", + "updates.published": "Published", + "updates.rate_limit_reset": "Rate limit resets at ", + "updates.platform": "Package", + "updates.no_platform_asset": "no package published for this platform", + "updates.download": "Download package", + "updates.downloading": "Downloading...", + "updates.download_saved": "Saved to ", + "updates.download_exists": "Already downloaded: ", "reports.title": "Player Reports", "reports.no_reports": "No reports yet.", @@ -179,13 +203,6 @@ "privacy_policy.agree": "I have read and understand this privacy policy, and I will ensure it is accessible to all players on my server.", "privacy_policy.confirm": "Confirm", - "player_logs.title": "Player Logs", - "player_logs.select_client": "Select a player...", - "player_logs.all_types": "All types", - "player_logs.refresh": "Refresh", - "player_logs.export": "Export JSON", - "player_logs.no_logs": "No logs.", - "table.type": "Type", "table.player": "Player", "table.detail": "Detail", diff --git a/src/Empostor.Server/Pages/themes/catppuccin/theme.json b/src/Empostor.Server/Pages/themes/catppuccin/theme.json new file mode 100644 index 0000000..55fd067 --- /dev/null +++ b/src/Empostor.Server/Pages/themes/catppuccin/theme.json @@ -0,0 +1,38 @@ +{ + "Id": "catppuccin", + "Name": "Catppuccin", + "Extends": null, + "Tokens": { + "--bg": "#EFF1F5", + "--s": "#E6E9EF", + "--b": "#DCE0E8", + "--t": "#4C4F69", + "--m": "#6C6F85", + "--a": "#1E66F5", + "--g": "#40A02B", + "--y": "#DF8E1D", + "--r": "#D20F39", + "--p": "#8839EF", + "--o": "#FE640B", + "--field": "#FFFFFF", + "--hover": "rgba(76,79,105,.05)", + "--on-a": "#FFFFFF" + }, + "DarkTokens": { + "--bg": "#1E1E2E", + "--s": "#313244", + "--b": "#45475A", + "--t": "#CDD6F4", + "--m": "#A6ADC8", + "--a": "#89B4FA", + "--g": "#A6E3A1", + "--y": "#F9E2AF", + "--r": "#F38BA8", + "--p": "#CBA6F7", + "--o": "#FAB387", + "--field": "#181825", + "--hover": "rgba(205,214,244,.06)", + "--on-a": "#1E1E2E" + }, + "CustomCss": "body{letter-spacing:.15px}\nh2{padding-bottom:8px;border-bottom:1px solid var(--b)}\n.form,.sc,.ig,table{border-radius:14px}\n.form,.sc{border:1px solid var(--b);box-shadow:0 1px 3px rgba(76,79,105,.05)}\n.form:hover,.sc:hover{border-color:var(--a);box-shadow:0 4px 16px rgba(137,180,250,.14)}\n.badge{border-radius:999px;padding:3px 10px;font-weight:600}\n.chip{border-radius:999px}\n.ni{border-radius:0 12px 12px 0;transition:background .15s ease,color .15s ease}\n.ni:hover{background:var(--hover)}\n.ni.active{background:rgba(137,180,250,.14);font-weight:600}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:999px;font-weight:600;padding-left:18px;padding-right:18px;transition:transform .12s ease,box-shadow .12s ease}\nbutton.bp:hover,button.bd:hover,button.bw:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(76,79,105,.18)}\ninput,select,textarea{border-radius:10px}\ninput:focus,select:focus,textarea:focus{border-color:var(--a);box-shadow:0 0 0 3px rgba(137,180,250,.18)}\nth{background:var(--s);font-weight:600}" +} diff --git a/src/Empostor.Server/Pages/themes/dracula/theme.json b/src/Empostor.Server/Pages/themes/dracula/theme.json new file mode 100644 index 0000000..9fe2316 --- /dev/null +++ b/src/Empostor.Server/Pages/themes/dracula/theme.json @@ -0,0 +1,38 @@ +{ + "Id": "dracula", + "Name": "Dracula", + "Extends": null, + "Tokens": { + "--bg": "#F7F7FB", + "--s": "#EFEFF6", + "--b": "#DFDFEC", + "--t": "#282A36", + "--m": "#6272A4", + "--a": "#7C4DFF", + "--g": "#1E9E52", + "--y": "#B08400", + "--r": "#D63031", + "--p": "#C2185B", + "--o": "#C4601A", + "--field": "#FFFFFF", + "--hover": "rgba(40,42,54,.05)", + "--on-a": "#FFFFFF" + }, + "DarkTokens": { + "--bg": "#282A36", + "--s": "#323444", + "--b": "#44475A", + "--t": "#F8F8F2", + "--m": "#A3ACCC", + "--a": "#BD93F9", + "--g": "#50FA7B", + "--y": "#F1FA8C", + "--r": "#FF5555", + "--p": "#FF79C6", + "--o": "#FFB86C", + "--field": "#21222C", + "--hover": "rgba(248,248,242,.06)", + "--on-a": "#282A36" + }, + "CustomCss": "body{letter-spacing:.15px}\nh2{padding-bottom:8px;border-bottom:1px solid var(--b)}\n.form,.sc,.ig,table{border-radius:12px}\n.form,.sc{box-shadow:0 1px 2px rgba(40,42,54,.06)}\n.form:hover{border-color:var(--a);box-shadow:0 3px 14px rgba(189,147,249,.14)}\n.ni{border-radius:10px;transition:background .15s ease,color .15s ease}\n.ni:hover{background:var(--hover)}\n.ni.active{background:rgba(189,147,249,.14);color:var(--a);font-weight:600}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:9px;font-weight:600;transition:transform .1s ease}\nbutton.bp:hover,button.bd:hover,button.bw:hover{transform:translateY(-1px)}\ninput,select,textarea{border-radius:9px}\ninput:focus,select:focus,textarea:focus{box-shadow:0 0 0 3px rgba(189,147,249,.22)}\n.pnl.active{animation:fadeIn .25s ease}\n@keyframes fadeIn{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}" +} diff --git a/src/Empostor.Server/Pages/themes/gruvbox/theme.json b/src/Empostor.Server/Pages/themes/gruvbox/theme.json new file mode 100644 index 0000000..f2ddc83 --- /dev/null +++ b/src/Empostor.Server/Pages/themes/gruvbox/theme.json @@ -0,0 +1,38 @@ +{ + "Id": "gruvbox", + "Name": "Gruvbox", + "Extends": null, + "Tokens": { + "--bg": "#FBF1C7", + "--s": "#F2E5BC", + "--b": "#E0CFA0", + "--t": "#3C3836", + "--m": "#7C6F64", + "--a": "#AF3A03", + "--g": "#79740E", + "--y": "#B57614", + "--r": "#9D0006", + "--p": "#8F3F71", + "--o": "#D65D0E", + "--field": "#FFFBF0", + "--hover": "rgba(60,56,54,.06)", + "--on-a": "#FBF1C7" + }, + "DarkTokens": { + "--bg": "#282828", + "--s": "#3C3836", + "--b": "#504945", + "--t": "#EBDBB2", + "--m": "#A89984", + "--a": "#FE8019", + "--g": "#B8BB26", + "--y": "#FABD2F", + "--r": "#FB4934", + "--p": "#D3869B", + "--o": "#D65D0E", + "--field": "#1D2021", + "--hover": "rgba(235,219,178,.06)", + "--on-a": "#282828" + }, + "CustomCss": "body{letter-spacing:.1px}\nh2{padding-bottom:8px;border-bottom:2px solid var(--b)}\n.form,.sc,.ig,table{border-radius:6px;border-width:2px}\n.ni{border-radius:0 8px 8px 0}\n.ni.active{font-weight:700}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:6px;font-weight:700;text-transform:none}\ninput,select,textarea{border-radius:6px;border-width:2px}\nth{font-weight:700;letter-spacing:.6px}\n.badge{border-radius:6px}\n.code{font-weight:700}" +} diff --git a/src/Empostor.Server/Pages/themes/midnight/theme.json b/src/Empostor.Server/Pages/themes/midnight/theme.json new file mode 100644 index 0000000..32d1414 --- /dev/null +++ b/src/Empostor.Server/Pages/themes/midnight/theme.json @@ -0,0 +1,38 @@ +{ + "Id": "midnight", + "Name": "Midnight OLED", + "Extends": null, + "Tokens": { + "--bg": "#F5F7FA", + "--s": "#EBEFF5", + "--b": "#D9E0EA", + "--t": "#0B1220", + "--m": "#5A6B85", + "--a": "#0A84FF", + "--g": "#1FA84F", + "--y": "#B57A00", + "--r": "#E5484D", + "--p": "#8E4EC6", + "--o": "#C4601A", + "--field": "#FFFFFF", + "--hover": "rgba(11,18,32,.05)", + "--on-a": "#FFFFFF" + }, + "DarkTokens": { + "--bg": "#000000", + "--s": "#0B0F14", + "--b": "#1C232C", + "--t": "#FFFFFF", + "--m": "#8B98A9", + "--a": "#4C9AFF", + "--g": "#3DDC84", + "--y": "#FFD60A", + "--r": "#FF453A", + "--p": "#BF5AF2", + "--o": "#FF9F0A", + "--field": "#080B10", + "--hover": "rgba(255,255,255,.05)", + "--on-a": "#000000" + }, + "CustomCss": "[data-theme=\"dark\"] body,body{background:var(--bg)}\n[data-theme=\"dark\"] header,[data-theme=\"dark\"] nav{background:#050709}\n[data-theme=\"dark\"] .sc,[data-theme=\"dark\"] .form,[data-theme=\"dark\"] table{border-color:#1C232C}\n[data-theme=\"dark\"] .ni.active{background:rgba(76,154,255,.16)}\n[data-theme=\"dark\"] .code{color:#4C9AFF;text-shadow:0 0 10px rgba(76,154,255,.5)}\n[data-theme=\"dark\"] .dot{box-shadow:0 0 8px var(--g)}\nh2{padding-bottom:8px;border-bottom:1px solid var(--b)}\n.form,.sc,.ig,table{border-radius:8px}\n.ni{border-left-width:3px}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:6px;font-weight:600}\ninput,select,textarea{border-radius:6px}\ninput:focus,select:focus,textarea:focus{box-shadow:0 0 0 3px rgba(76,154,255,.22)}" +} diff --git a/src/Empostor.Server/Pages/themes/nord/theme.json b/src/Empostor.Server/Pages/themes/nord/theme.json new file mode 100644 index 0000000..d603b75 --- /dev/null +++ b/src/Empostor.Server/Pages/themes/nord/theme.json @@ -0,0 +1,38 @@ +{ + "Id": "nord", + "Name": "Nord", + "Extends": null, + "Tokens": { + "--bg": "#ECEFF4", + "--s": "#E5E9F0", + "--b": "#D8DEE9", + "--t": "#2E3440", + "--m": "#4C566A", + "--a": "#5E81AC", + "--g": "#4C8C4A", + "--y": "#B58A28", + "--r": "#BF616A", + "--p": "#8F6FA8", + "--o": "#D08770", + "--field": "#FFFFFF", + "--hover": "rgba(46,52,64,.05)", + "--on-a": "#FFFFFF" + }, + "DarkTokens": { + "--bg": "#2E3440", + "--s": "#3B4252", + "--b": "#4C566A", + "--t": "#ECEFF4", + "--m": "#97A1B5", + "--a": "#88C0D0", + "--g": "#A3BE8C", + "--y": "#EBCB8B", + "--r": "#BF616A", + "--p": "#B48EAD", + "--o": "#D08770", + "--field": "#2B3140", + "--hover": "rgba(236,239,244,.06)", + "--on-a": "#2E3440" + }, + "CustomCss": "body{letter-spacing:.1px}\nh2{border-bottom:1px solid var(--b);padding-bottom:8px}\n.form,.sc,.ig,table{border-radius:8px}\n.ni{border-radius:0 8px 8px 0}\nbutton.bp,button.bd,button.bw{border-radius:7px;letter-spacing:.2px}\ninput:focus,select:focus,textarea:focus{box-shadow:0 0 0 3px rgba(94,129,172,.2)}\n.hdr{background:var(--s)}" +} diff --git a/src/Empostor.Server/Pages/themes/rose-pine/theme.json b/src/Empostor.Server/Pages/themes/rose-pine/theme.json new file mode 100644 index 0000000..e6e8106 --- /dev/null +++ b/src/Empostor.Server/Pages/themes/rose-pine/theme.json @@ -0,0 +1,38 @@ +{ + "Id": "rose-pine", + "Name": "Rosé Pine", + "Extends": null, + "Tokens": { + "--bg": "#FAF4ED", + "--s": "#F2E9E1", + "--b": "#E4DAD2", + "--t": "#575279", + "--m": "#797593", + "--a": "#286983", + "--g": "#56949F", + "--y": "#EA9D34", + "--r": "#B4637A", + "--p": "#907AA9", + "--o": "#D7827E", + "--field": "#FFFCF9", + "--hover": "rgba(87,82,121,.05)", + "--on-a": "#FFFFFF" + }, + "DarkTokens": { + "--bg": "#191724", + "--s": "#1F1D2E", + "--b": "#26233A", + "--t": "#E0DEF4", + "--m": "#908CAA", + "--a": "#C4A7E7", + "--g": "#31748F", + "--y": "#F6C177", + "--r": "#EB6F92", + "--p": "#C4A7E7", + "--o": "#EA9D34", + "--field": "#12111A", + "--hover": "rgba(224,222,244,.06)", + "--on-a": "#191724" + }, + "CustomCss": "body{font-family:'Georgia','Iowan Old Style',ui-serif,serif;letter-spacing:.15px}\nh1,h2,h3,.nlbl{font-family:ui-serif,Georgia,serif}\nh2{padding-bottom:8px;border-bottom:1px solid var(--b)}\n.form,.sc,.ig,table{border-radius:16px}\n.form,.sc{box-shadow:0 1px 4px rgba(87,82,121,.05)}\n.ni{border-radius:0 12px 12px 0}\n.ni.active{background:rgba(196,167,231,.14);font-weight:600}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:12px;font-weight:600}\ninput,select,textarea{border-radius:12px}\ninput:focus,select:focus,textarea:focus{border-color:var(--a);box-shadow:0 0 0 3px rgba(196,167,231,.2)}\nth{font-style:italic;font-weight:600}\n.pnl.active{animation:rpIn .3s ease}\n@keyframes rpIn{from{opacity:0}to{opacity:1}}" +} diff --git a/src/Empostor.Server/Pages/themes/solarized/theme.json b/src/Empostor.Server/Pages/themes/solarized/theme.json new file mode 100644 index 0000000..6cb37a7 --- /dev/null +++ b/src/Empostor.Server/Pages/themes/solarized/theme.json @@ -0,0 +1,38 @@ +{ + "Id": "solarized", + "Name": "Solarized", + "Extends": null, + "Tokens": { + "--bg": "#FDF6E3", + "--s": "#EEE8D5", + "--b": "#DDD6C1", + "--t": "#073642", + "--m": "#657B83", + "--a": "#268BD2", + "--g": "#7A8C00", + "--y": "#B58900", + "--r": "#DC322F", + "--p": "#6C71C4", + "--o": "#CB4B16", + "--field": "#FFFBF0", + "--hover": "rgba(7,54,66,.05)", + "--on-a": "#FFFFFF" + }, + "DarkTokens": { + "--bg": "#002B36", + "--s": "#073642", + "--b": "#14505F", + "--t": "#EEE8D5", + "--m": "#93A1A1", + "--a": "#3FA3E8", + "--g": "#B4C43A", + "--y": "#D4A72C", + "--r": "#E85550", + "--p": "#8B90E0", + "--o": "#E5763F", + "--field": "#00252E", + "--hover": "rgba(238,232,213,.06)", + "--on-a": "#002B36" + }, + "CustomCss": "body{font-family:'Menlo','Consolas','DejaVu Sans Mono',ui-monospace,monospace;font-size:13px}\nh1,h2{font-weight:600}\nh2{padding-bottom:8px;border-bottom:1px solid var(--b)}\n.form,.sc,.ig,table{border-radius:4px}\n.ni{border-left-width:3px}\nth{background:var(--s);font-weight:700}\nbutton.bp,button.bd,button.bw{border-radius:4px;font-weight:600}\ninput,select,textarea{border-radius:4px}\n.code,.ip,td.mono{font-weight:600}" +} diff --git a/src/Empostor.Server/Pages/themes/tokyo-night/theme.json b/src/Empostor.Server/Pages/themes/tokyo-night/theme.json new file mode 100644 index 0000000..d815186 --- /dev/null +++ b/src/Empostor.Server/Pages/themes/tokyo-night/theme.json @@ -0,0 +1,38 @@ +{ + "Id": "tokyo-night", + "Name": "Tokyo Night", + "Extends": null, + "Tokens": { + "--bg": "#F6F7FB", + "--s": "#EDEFF7", + "--b": "#DCE0EC", + "--t": "#1A1B26", + "--m": "#565A6E", + "--a": "#2E5FA3", + "--g": "#3D7A33", + "--y": "#8F5E15", + "--r": "#8C4351", + "--p": "#5A4A78", + "--o": "#965027", + "--field": "#FFFFFF", + "--hover": "rgba(26,27,38,.05)", + "--on-a": "#FFFFFF" + }, + "DarkTokens": { + "--bg": "#1A1B26", + "--s": "#24283B", + "--b": "#343B58", + "--t": "#C0CAF5", + "--m": "#7C86B0", + "--a": "#7AA2F7", + "--g": "#9ECE6A", + "--y": "#E0AF68", + "--r": "#F7768E", + "--p": "#BB9AF7", + "--o": "#FF9E64", + "--field": "#16161E", + "--hover": "rgba(192,202,245,.06)", + "--on-a": "#1A1B26" + }, + "CustomCss": "body{letter-spacing:.1px}\nh2{padding-bottom:8px;border-bottom:1px solid var(--b);letter-spacing:.6px}\n.form,.sc,.ig,table{border-radius:10px}\n.sc{background:linear-gradient(160deg,var(--s),transparent)}\n.ni{border-radius:8px;transition:background .18s ease,color .18s ease}\n.ni:hover{background:var(--hover);transform:translateX(2px)}\n.ni.active{background:rgba(122,162,247,.12)}\n.form:hover{border-color:var(--a)}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:8px;font-weight:600;transition:transform .12s ease,box-shadow .12s ease}\nbutton.bp:hover,button.bd:hover,button.bw:hover{transform:translateY(-1px)}\nbutton.bp{box-shadow:0 1px 3px rgba(122,162,247,.3)}\ninput,select,textarea{border-radius:8px}\ninput:focus,select:focus,textarea:focus{box-shadow:0 0 0 3px rgba(122,162,247,.2)}\n.code{text-shadow:0 0 8px rgba(122,162,247,.35)}\n.pnl.active{animation:tnIn .25s ease}\n@keyframes tnIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}" +} diff --git a/src/Empostor.Server/Program.cs b/src/Empostor.Server/Program.cs index b350da2..e33c05d 100644 --- a/src/Empostor.Server/Program.cs +++ b/src/Empostor.Server/Program.cs @@ -112,7 +112,6 @@ namespace Empostor.Server services.Configure(host.Configuration.GetSection(TimeoutConfig.Section)); services.Configure(host.Configuration.GetSection(HttpServerConfig.Section)); services.Configure(host.Configuration.GetSection(AdminConfig.Section)); - services.Configure(host.Configuration.GetSection(HplpConfig.Section)); services.Configure(host.Configuration.GetSection(AuthApiConfig.Section)); services.AddSingleton(); diff --git a/src/Empostor.Server/Service/Api/HplpStore.cs b/src/Empostor.Server/Service/Api/HplpStore.cs index b15bd66..2b6b8e1 100644 --- a/src/Empostor.Server/Service/Api/HplpStore.cs +++ b/src/Empostor.Server/Service/Api/HplpStore.cs @@ -3,29 +3,20 @@ using System.Threading.Tasks; using Empostor.Api.Config; using Empostor.Api.Service; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace Empostor.Server.Service.Api; +/// +/// Runtime HPLP settings. Data/HplpData.json is the only source — the admin panel reads +/// and writes it, and nothing is taken from config.json. +/// public sealed class HplpStore : JsonDataStore { - public HplpStore(ILogger logger, IOptions config) + public HplpStore(ILogger logger) : base(logger, legacyPath: "hplp.json") { JsonOpts.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; - - // Try loading from persisted file Load(); - - // If no persisted data, fall back to config.json defaults - if (string.IsNullOrEmpty(RegionId) && string.IsNullOrEmpty(RegionName) && string.IsNullOrEmpty(PublicUrl)) - { - var cfg = config.Value; - Enabled = cfg.Enabled; - RegionId = cfg.RegionId; - RegionName = cfg.RegionName; - PublicUrl = cfg.PublicUrl; - } } public bool Enabled { get; set; } diff --git a/src/Empostor.Server/config.json b/src/Empostor.Server/config.json index bc168ce..efe4c34 100644 --- a/src/Empostor.Server/config.json +++ b/src/Empostor.Server/config.json @@ -50,11 +50,5 @@ "Admin": { "Password": "", "MarketplaceUrl": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/plugins.json" - }, - "HPLP": { - "Enabled": false, - "RegionId": "default", - "RegionName": "Empostor Server", - "PublicUrl": "" } }