1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
using System.IO;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AdamsToolkit.Core;
public class DiscordUser
{
[JsonPropertyName("userId")] public string Id { get; set; } = "";
[JsonPropertyName("username")] public string Username { get; set; } = "";
[JsonPropertyName("avatar")] public string AvatarUrl { get; set; } = "";
public string DisplayName => Username;
}
/// <summary>
/// 1. A app pede um código ao backend (bot Adao.exe, via Caddy https); a resposta
/// traz também um authUrl (OAuth implicit no browser — 1 clique em "Autorizar").
/// 2. Caminho primário: abrir o authUrl no browser. Fallback: o jogador corre
/// /toolkit <código> no Discord Shame & Adão.
/// 3. O bot confirma que está no servidor e aprova; a app faz poll até receber o token.
/// 4. Nos arranques seguintes o token é revalidado (a presença no guild é verificada de novo).
/// </summary>
public static class DiscordAuth
{
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(15) };
private static string ApiBase => ConfigService.Current.AuthApiBase.TrimEnd('/');
private static string SessionPath => Path.Combine(ConfigService.DataDir, "session.json");
public record PollResult(string Status, string? Token, DiscordUser? User);
private class PollResponse
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("token")] public string? Token { get; set; }
[JsonPropertyName("userId")] public string UserId { get; set; } = "";
[JsonPropertyName("username")] public string Username { get; set; } = "";
[JsonPropertyName("avatar")] public string Avatar { get; set; } = "";
}
private class ValidateResponse
{
[JsonPropertyName("ok")] public bool Ok { get; set; }
[JsonPropertyName("userId")] public string UserId { get; set; } = "";
[JsonPropertyName("username")] public string Username { get; set; } = "";
[JsonPropertyName("avatar")] public string Avatar { get; set; } = "";
}
public record StartResult(string Code, string? AuthUrl);
/// <summary>Pede um código de emparelhamento novo (+ URL OAuth para login de 1 clique);
/// null se o backend estiver inacessível.</summary>
public static async Task<StartResult?> StartPairingAsync()
{
try
{
var res = await Http.PostAsync($"{ApiBase}/toolkit/start", null);
if (!res.IsSuccessStatusCode) return null;
var root = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
var code = root.GetProperty("code").GetString();
if (code == null) return null;
var authUrl = root.TryGetProperty("authUrl", out var a) ? a.GetString() : null;
return new StartResult(code, authUrl);
}
catch { return null; }
}
/// <summary>Estado do código: pending / ok (com token+user) / expired / error.</summary>
public static async Task<PollResult> PollAsync(string code)
{
try
{
var json = await Http.GetStringAsync(
$"{ApiBase}/toolkit/poll?code={Uri.EscapeDataString(code)}");
var r = JsonSerializer.Deserialize<PollResponse>(json);
if (r == null) return new PollResult("error", null, null);
if (r.Status != "ok") return new PollResult(r.Status, null, null);
return new PollResult("ok", r.Token,
new DiscordUser { Id = r.UserId, Username = r.Username, AvatarUrl = r.Avatar });
}
catch { return new PollResult("error", null, null); }
}
/// <summary>User null + NetworkError false = acesso revogado (saiu do guild).</summary>
public record ValidateResult(DiscordUser? User, bool NetworkError);
/// <summary>Revalida o token no backend (confirma presença no guild).</summary>
public static async Task<ValidateResult> ValidateAsync(string token)
{
try
{
var json = await Http.GetStringAsync(
$"{ApiBase}/toolkit/validate?token={Uri.EscapeDataString(token)}");
var r = JsonSerializer.Deserialize<ValidateResponse>(json);
if (r is not { Ok: true }) return new ValidateResult(null, false);
return new ValidateResult(
new DiscordUser { Id = r.UserId, Username = r.Username, AvatarUrl = r.Avatar }, false);
}
catch { return new ValidateResult(null, true); }
}
/// <summary>Ping de presença (a app está aberta) — alimenta o painel staff "a usar agora".</summary>
public static async Task PingAsync(string token)
{
try { await Http.GetStringAsync($"{ApiBase}/toolkit/ping?token={Uri.EscapeDataString(token)}"); }
catch { }
}
// ---- modo offline ----
// Backend em baixo (VPS/sslip.io) não pode trancar toda a gente fora: cada
// validação OK guarda um snapshot local; se o servidor de verificação não
// responder, a app deixa entrar com esse snapshot durante 7 dias. Uma resposta
// NEGATIVA (revogado) apaga o snapshot — revogação continua a bloquear na hora.
private static string OfflinePath => Path.Combine(ConfigService.DataDir, "offline-user.json");
private const int OfflineGraceDays = 7;
private class OfflineSnapshot
{
[JsonPropertyName("user")] public DiscordUser? User { get; set; }
[JsonPropertyName("lastOkUtc")] public DateTime LastOkUtc { get; set; }
}
public static void SaveOfflineSnapshot(DiscordUser user)
{
try
{
File.WriteAllText(OfflinePath, JsonSerializer.Serialize(
new OfflineSnapshot { User = user, LastOkUtc = DateTime.UtcNow }));
}
catch { }
}
/// <summary>User do último login validado há <7 dias; null se não houver ou já expirou.</summary>
public static DiscordUser? LoadOfflineUser()
{
try
{
if (!File.Exists(OfflinePath)) return null;
var s = JsonSerializer.Deserialize<OfflineSnapshot>(File.ReadAllText(OfflinePath));
if (s?.User == null) return null;
var age = DateTime.UtcNow - s.LastOkUtc;
return age >= TimeSpan.Zero && age <= TimeSpan.FromDays(OfflineGraceDays) ? s.User : null;
}
catch { return null; }
}
public static void ClearOfflineSnapshot()
{
try { File.Delete(OfflinePath); } catch { }
}
// ---- sessão persistida (auto-login no próximo arranque) ----
public static void SaveSession(string token)
{
try { File.WriteAllText(SessionPath, token); } catch { }
}
public static string? LoadSession()
{
try
{
if (!File.Exists(SessionPath)) return null;
var token = File.ReadAllText(SessionPath).Trim();
return token.Length > 0 ? token : null;
}
catch { return null; }
}
public static void ClearSession()
{
try { File.Delete(SessionPath); } catch { }
}
}