Skip to content

Commit 3b6fd73

Browse files
VortexUKclaude
andcommitted
v0.1.14: admin-gated URL, allowed servers, structured blacklist, event log, UI polish
Major feature pass alongside server PR #24 (allowed_servers + is_admin on /api/auth/whoami + strict ingest gate). Backward-compatible with v0.1.13 config files on disk — legacy BlacklistedCharacters migrates to the new structured BlacklistedEntries on Load. Settings panel changes - Server URL field locks (with 🔒 glyph + tooltip) for non-admin accounts. Plugin opportunistically calls /auth/whoami on startup and after every Test Connection / Save click; URL unlocks only when the server returns is_admin=true. Fail-CLOSED — outdated server (no field) → URL locked. - New "✦ ALLOWED SERVERS" card showing the read-only list the site permits uploads from. Defaults to ["Varsoon", "Wuoshi"] until whoami returns the list. Inline warning when the current logging server isn't allowed. - New "▤ EVENT LOG" card in the right column (TableLayoutPanel split). Captures + uploads + HTTP request/response summaries with severity colours, toolbar (Clear / Copy / Auto-scroll / category filters), and a 500-entry ring buffer. Bearer token never appears in any log line. - "Don't upload as" blacklist now a row-based editor with [Character] [Server (dropdown)] [✕ Remove] rows + an Add button. Server dropdown is pre-populated with the allowed-servers list. Doubled visible area (84px → 180px). Fixes the issue where same-named alts on different servers couldn't be selectively blacklisted. UI polish - Embedded Cinzel variable font (SIL OFL 1.1, ~125KB) for the EQ2 LEXICON UPLOADER title and all card section headings. Loaded via PrivateFontCollection so users don't need it installed; body text stays Segoe UI for legibility. - Rounded buttons (6px radius) via custom RoundedButton control with proper parent-bg painting in OnPaintBackground so corners blend cleanly into the card behind. Rounded card borders (8px) via the same path helper. - Spacing breathing room: root padding 16 → 24/20/24/24; card-to-card gap 12 → 16; header-to-first-card gap 12 → 18. Core - UploadClient: ExtractJsonBool + ExtractJsonStringArray helpers (same fail-closed posture as the existing ExtractJsonString). TestConnection returns IsAdmin (default false) and AllowedServers (default null → caller substitutes built-in default). New RequestCompleted event fires after every HTTP exchange with anonymised summary (verb, URL, status, duration, success, 200-char response excerpt) — never the token, never the payload body. - New EventLog Core type (thread-safe ring buffer, EntryAdded event, Snapshot, Clear). Pure, no ACT/WinForms refs. - PluginConfig: new BlacklistedEntry (Character + Server) class. IsBlacklisted now takes (character, server). Server="" means "any server" (legacy semantics). Load() migrates legacy BlacklistedCharacters → BlacklistedEntries with Server=""; idempotent. Tests: 234 passing (up from 215). New coverage for ExtractJsonBool, ExtractJsonStringArray, EventLog ring-buffer + thread-safety, the new (character, server) blacklist match semantics, and the legacy list-of-string → list-of-entry migration on Load. Server-side contract for the new whoami fields is documented in CLAUDE.md → "Required server-side changes". Server PR landed as VortexUK/EQ2Lexicon#24 — the plugin reads what the server now ships. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 60036d6 commit 3b6fd73

17 files changed

Lines changed: 2832 additions & 116 deletions

CLAUDE.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,22 @@ Every upload includes an `X-Lexicon-Signature` header: HMAC-SHA256 of the reques
139139

140140
**Rollout coordination with the server**: ship the server's signature validator in *opportunistic* mode first (validate only if header present, reject only if validation fails) so existing v0.1.7 installs keep uploading. Flip to *strict* mode (require the header) once telemetry shows ≥98% of uploads carry it — pulled from the `User-Agent` header which UploadClient sets to `EQ2LexiconACTPlugin/<assembly version>`.
141141

142+
## Required server-side changes (pending in [VortexUK/EQ2Lexicon](https://github.com/VortexUK/EQ2Lexicon))
143+
144+
The plugin already wires UI for two fields that the production server does not yet emit. Until the server PRs land, the plugin falls back to safe defaults (URL field stays locked; allowed-servers card shows a built-in list).
145+
146+
### `is_admin` on `/api/auth/whoami`
147+
148+
Add a boolean `is_admin` to the JSON response. Drives the Server URL field's editability gate in the plugin — only admins can change the endpoint. Fail-CLOSED in the plugin: if the field is missing the URL stays locked, so a non-admin can never see a writable URL field by simply pointing at an old build of the server. Same opportunistic-rollout pattern as the HMAC header in v0.1.8 — additive field, ignored by old clients, used by new ones.
149+
150+
### `allowed_servers` on `/api/auth/whoami`
151+
152+
Add an array of EQ2 server name strings (e.g. `["Varsoon", "Wuoshi"]`) the user is permitted to upload from. Drives the ALLOWED SERVERS card in the settings panel — read-only display so the user knows up-front which characters' parses will reach the site. The list could be a global default, per-guild, or per-user — server's choice; the plugin just renders what it's given. Sanitisation in the plugin caps each entry at 64 chars and the whole array at 32 entries to keep a hostile/buggy server from breaking the UI layout.
153+
154+
When absent, the plugin uses a built-in default of `["Varsoon", "Wuoshi"]` (the two active English-language EQ2 TLE servers as of 2026). When the server starts returning the field, the built-in default is replaced by whatever the site says on the next whoami round-trip.
155+
156+
Future enforcement: the server should *also* validate `logger_server` on incoming uploads against the same list and reject mismatched uploads with a 403. The plugin's display is courtesy/transparency; the real gate is the server.
157+
142158
## Update awareness (v0.1.8+)
143159

144160
The plugin fetches `https://api.github.com/repos/VortexUK/EQ2LexiconACTPlugin/releases` once per ACT session, compares the assembly version to the published tags, and:

THIRD_PARTY_NOTICES.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Third-party notices
2+
3+
The EQ2 Lexicon ACT Plugin redistributes the following third-party assets.
4+
5+
## Cinzel font
6+
7+
The plugin embeds [Cinzel](https://github.com/google/fonts/tree/main/ofl/cinzel) (the variable font `Cinzel[wght].ttf`) by Natanael Gama, licensed under the [SIL Open Font License 1.1](https://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL).
8+
9+
The full OFL text is bundled inside the DLL at `src/Resources/Fonts/OFL.txt` and reproduced here for convenience: <https://github.com/google/fonts/blob/main/ofl/cinzel/OFL.txt>
10+
11+
The OFL grants permission to use, embed, and redistribute the font under the conditions in the license — most relevantly:
12+
13+
- The font may not be sold by itself.
14+
- The font (or modified versions) when bundled inside a software package may be distributed at no extra charge, with the OFL included.
15+
16+
We meet these conditions by shipping the unmodified Cinzel variable font as an embedded resource in `EQ2Lexicon.ACTPlugin.dll` along with the OFL.txt notice file.

src/Core/EQ2Lexicon.ACTPlugin.Core.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
Build metadata is mirrored from the UI csproj so this assembly's
1919
version tracks the user-facing plugin version.
2020
-->
21-
<Version>0.1.13</Version>
21+
<Version>0.1.14</Version>
2222
<Company>EQ2 Lexicon</Company>
2323
<Product>EQ2 Lexicon Uploader (Core)</Product>
2424
<Description>Pure payload / config / HTTP layer shared by EQ2Lexicon.ACTPlugin.</Description>

src/Core/EventLog.cs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
using System;
2+
using System.Collections.Generic;
3+
4+
namespace EQ2Lexicon.ACTPlugin
5+
{
6+
/// <summary>
7+
/// Severity tier for an event log entry. Drives the colour the
8+
/// settings panel paints the row with. Kept separate from the
9+
/// HTTP status / capture-result distinctions so the UI doesn't
10+
/// have to translate "success" booleans into colours in five
11+
/// different call sites.
12+
/// </summary>
13+
public enum EventSeverity
14+
{
15+
Info,
16+
Success,
17+
Warning,
18+
Error,
19+
}
20+
21+
/// <summary>
22+
/// One row in the in-memory event log. UTC timestamp so the panel
23+
/// can render in local time without ambiguity when ACT moves
24+
/// between time zones (rare but possible — laptop user travels).
25+
/// Plain DTO; no behaviour.
26+
/// </summary>
27+
public sealed class LogEntry
28+
{
29+
public DateTime At { get; set; } // UTC
30+
public EventSeverity Severity { get; set; }
31+
/// <summary>"capture" | "upload" | "http" — used by the panel's filter checkboxes.</summary>
32+
public string Category { get; set; } = "";
33+
public string Message { get; set; } = "";
34+
}
35+
36+
/// <summary>
37+
/// Tiny thread-safe ring-buffered event log surfaced in the settings
38+
/// panel. One instance per Plugin lifetime; subscribers (the
39+
/// settings panel) receive each entry via <see cref="EntryAdded"/>
40+
/// AND can pull the full backlog via <see cref="Snapshot"/> when
41+
/// they first attach.
42+
///
43+
/// Lives in Core deliberately — no ACT or WinForms refs means it's
44+
/// unit-testable without spinning up a UI thread, and a future
45+
/// headless CLI could reuse it. Cap is fixed at construction so a
46+
/// long-running ACT session doesn't accumulate unbounded entries.
47+
/// </summary>
48+
public sealed class EventLog
49+
{
50+
private readonly int _cap;
51+
private readonly LinkedList<LogEntry> _entries = new LinkedList<LogEntry>();
52+
private readonly object _lock = new object();
53+
54+
// ID monotonically increments. The UI uses it to detect "I
55+
// missed entries while the marshal was queued" (entries
56+
// evicted between subscribe and first paint).
57+
private long _nextId;
58+
59+
/// <summary>
60+
/// Default cap is 500 entries — enough for a multi-hour raid
61+
/// session at typical capture rates (~1 entry per encounter +
62+
/// ~2 per upload). Override only in tests where eviction
63+
/// timing matters.
64+
/// </summary>
65+
public EventLog(int capacity = 500)
66+
{
67+
if (capacity < 1) capacity = 1;
68+
_cap = capacity;
69+
}
70+
71+
public int Capacity => _cap;
72+
73+
/// <summary>
74+
/// Total number of entries ever logged (including evicted).
75+
/// Surfaced in the UI footer ("showing N of M (cap C)") so the
76+
/// user knows when older events have rolled off.
77+
/// </summary>
78+
public long TotalLogged
79+
{
80+
get
81+
{
82+
lock (_lock) { return _nextId; }
83+
}
84+
}
85+
86+
/// <summary>
87+
/// Raised whenever a new entry is added. May fire on any
88+
/// thread — subscribers MUST marshal if they touch UI.
89+
/// Handler exceptions are swallowed so a buggy subscriber
90+
/// can't break the log pipeline for other listeners.
91+
/// </summary>
92+
public event Action<LogEntry>? EntryAdded;
93+
94+
public void Log(EventSeverity severity, string category, string message)
95+
{
96+
var entry = new LogEntry
97+
{
98+
At = DateTime.UtcNow,
99+
Severity = severity,
100+
Category = category ?? "",
101+
Message = message ?? "",
102+
};
103+
lock (_lock)
104+
{
105+
_entries.AddLast(entry);
106+
_nextId++;
107+
while (_entries.Count > _cap)
108+
{
109+
_entries.RemoveFirst();
110+
}
111+
}
112+
var handler = EntryAdded;
113+
if (handler == null) return;
114+
foreach (var subscriber in handler.GetInvocationList())
115+
{
116+
try
117+
{
118+
((Action<LogEntry>)subscriber)(entry);
119+
}
120+
catch
121+
{
122+
// One subscriber must not break the rest.
123+
}
124+
}
125+
}
126+
127+
/// <summary>
128+
/// Snapshot of the current entries, oldest first. The list is
129+
/// a copy — safe to enumerate without holding the log lock.
130+
/// </summary>
131+
public IReadOnlyList<LogEntry> Snapshot()
132+
{
133+
lock (_lock)
134+
{
135+
var copy = new List<LogEntry>(_entries.Count);
136+
copy.AddRange(_entries);
137+
return copy;
138+
}
139+
}
140+
141+
/// <summary>
142+
/// Empty the log. The UI's "Clear" button calls this. Does
143+
/// NOT reset <see cref="TotalLogged"/> — the cumulative
144+
/// counter keeps growing so we can still tell whether
145+
/// anything has happened since startup.
146+
/// </summary>
147+
public void Clear()
148+
{
149+
lock (_lock)
150+
{
151+
_entries.Clear();
152+
}
153+
}
154+
}
155+
}

src/Core/PluginConfig.cs

Lines changed: 107 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@
88

99
namespace EQ2Lexicon.ACTPlugin
1010
{
11+
/// <summary>
12+
/// One row of the (Character, Server) blacklist surfaced in the
13+
/// settings panel. Plain DTO; equality / serialization use the
14+
/// two string properties directly. A blank Server means "match
15+
/// any EQ2 server" — the legacy v0.1.13 semantics.
16+
/// </summary>
17+
public class BlacklistedEntry
18+
{
19+
[XmlAttribute("character")]
20+
public string Character { get; set; } = "";
21+
22+
[XmlAttribute("server")]
23+
public string Server { get; set; } = "";
24+
}
25+
1126
/// <summary>
1227
/// User-configurable settings, persisted as XML.
1328
///
@@ -58,22 +73,71 @@ public class PluginConfig
5873
public bool UploadEnabled { get; set; } = false;
5974

6075
/// <summary>
61-
/// Character names to NEVER upload as. Encounters where ACT's
62-
/// active logging character is in this list are skipped silently.
63-
/// Useful for alts you don't want to attribute parses to (e.g. a
64-
/// non-guild bank toon, low-level alts, etc.). Match is
65-
/// case-insensitive on exact name.
76+
/// LEGACY — character-name-only blacklist. v0.1.13 and earlier
77+
/// stored a flat list of names which matched regardless of EQ2
78+
/// server, so a player with the same character name on two
79+
/// servers (e.g. an alt on Wuoshi sharing a name with a main on
80+
/// Varsoon) couldn't blacklist one without blacklisting both.
81+
///
82+
/// Still serialized as <c>&lt;BlacklistedCharacters&gt;</c> in
83+
/// the XML so an existing v0.1.13 config file can be loaded
84+
/// without data loss. On <see cref="Load"/>, any entries here
85+
/// are migrated into <see cref="BlacklistedEntries"/> (with
86+
/// Server="" = "any server", preserving the legacy semantics)
87+
/// and this list is cleared before the next Save persists.
6688
/// </summary>
6789
[XmlArray("BlacklistedCharacters")]
6890
[XmlArrayItem("Character")]
6991
public List<string> BlacklistedCharacters { get; set; } = new List<string>();
7092

71-
public bool IsBlacklisted(string characterName)
93+
/// <summary>
94+
/// Structured blacklist replacing <see cref="BlacklistedCharacters"/>.
95+
/// Each entry is a (Character, Server) pair. A blank Server
96+
/// matches any server (i.e. the legacy semantics). Encounters
97+
/// where ACT's active logging character matches an entry AND
98+
/// the current server is allowed by that entry are skipped
99+
/// silently. Match is case-insensitive on both fields.
100+
/// </summary>
101+
[XmlArray("BlacklistedEntries")]
102+
[XmlArrayItem("Entry")]
103+
public List<BlacklistedEntry> BlacklistedEntries { get; set; } = new List<BlacklistedEntry>();
104+
105+
/// <summary>
106+
/// Returns true when <paramref name="characterName"/> on
107+
/// <paramref name="serverName"/> should NOT upload. Matches:
108+
/// (a) any BlacklistedEntries row whose Character equals the
109+
/// given name AND whose Server is blank (= any) or equals
110+
/// the given server name; PLUS
111+
/// (b) any legacy BlacklistedCharacters entry that equals the
112+
/// given name — kept so a unit test that constructs a
113+
/// PluginConfig in memory without going through Load()
114+
/// still gets the old semantics. After Load() migrates
115+
/// them, the legacy list is empty and (b) is a no-op.
116+
/// </summary>
117+
public bool IsBlacklisted(string characterName, string serverName)
72118
{
73119
if (string.IsNullOrWhiteSpace(characterName)) return false;
74-
return BlacklistedCharacters.Any(b =>
75-
!string.IsNullOrWhiteSpace(b) &&
76-
string.Equals(b.Trim(), characterName.Trim(), StringComparison.OrdinalIgnoreCase));
120+
var cName = characterName.Trim();
121+
var sName = (serverName ?? "").Trim();
122+
123+
foreach (var entry in BlacklistedEntries)
124+
{
125+
if (entry == null) continue;
126+
if (string.IsNullOrWhiteSpace(entry.Character)) continue;
127+
if (!string.Equals(entry.Character.Trim(), cName, StringComparison.OrdinalIgnoreCase)) continue;
128+
// Blank server in the entry means "any server".
129+
if (string.IsNullOrWhiteSpace(entry.Server)) return true;
130+
if (string.Equals(entry.Server.Trim(), sName, StringComparison.OrdinalIgnoreCase)) return true;
131+
}
132+
133+
// Legacy list — treated as "any server" matches. Empty
134+
// after Load() runs the migration.
135+
foreach (var legacy in BlacklistedCharacters)
136+
{
137+
if (string.IsNullOrWhiteSpace(legacy)) continue;
138+
if (string.Equals(legacy.Trim(), cName, StringComparison.OrdinalIgnoreCase)) return true;
139+
}
140+
return false;
77141
}
78142

79143
// -------------------------------------------------------------------
@@ -159,6 +223,7 @@ public static PluginConfig Load(string path)
159223
{
160224
loaded.ServerUrl = DefaultServerUrl;
161225
}
226+
loaded.MigrateLegacyBlacklist();
162227
return loaded;
163228
}
164229
}
@@ -171,6 +236,39 @@ public static PluginConfig Load(string path)
171236
}
172237
}
173238

239+
/// <summary>
240+
/// One-shot migration: copy any names from the legacy
241+
/// <see cref="BlacklistedCharacters"/> list into the
242+
/// structured <see cref="BlacklistedEntries"/> with Server=""
243+
/// (= match any server, preserving the v0.1.13 semantics),
244+
/// then clear the legacy list so the next Save persists only
245+
/// the new shape. Idempotent — running twice is a no-op
246+
/// because the second pass sees an empty legacy list.
247+
///
248+
/// De-duplicates: a legacy name that's already represented
249+
/// (same character, any-server entry) in the new list is
250+
/// skipped rather than added twice.
251+
/// </summary>
252+
internal void MigrateLegacyBlacklist()
253+
{
254+
if (BlacklistedCharacters == null || BlacklistedCharacters.Count == 0) return;
255+
if (BlacklistedEntries == null) BlacklistedEntries = new List<BlacklistedEntry>();
256+
257+
foreach (var legacy in BlacklistedCharacters)
258+
{
259+
if (string.IsNullOrWhiteSpace(legacy)) continue;
260+
var name = legacy.Trim();
261+
bool alreadyPresent = BlacklistedEntries.Any(e =>
262+
e != null
263+
&& !string.IsNullOrWhiteSpace(e.Character)
264+
&& string.IsNullOrWhiteSpace(e.Server)
265+
&& string.Equals(e.Character.Trim(), name, StringComparison.OrdinalIgnoreCase));
266+
if (alreadyPresent) continue;
267+
BlacklistedEntries.Add(new BlacklistedEntry { Character = name, Server = "" });
268+
}
269+
BlacklistedCharacters.Clear();
270+
}
271+
174272
public void Save(string path)
175273
{
176274
var dir = Path.GetDirectoryName(path);

0 commit comments

Comments
 (0)