using System.Collections.Concurrent; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Campus_live_rewrite.api.dbs; namespace Campus_live_rewrite.api.server { /// /// Linked social accounts (Settings > Services): the player signs in to the service /// (OAuth 2 authorization code, Steam via OpenID), we keep only who they are there (id, handle, /// profile url) and a per-service "show on my profile" switch. Nothing is typed in by hand, /// so a handle on a profile is always a verified one. /// /// Each provider needs its own developer app; the credentials live ONLY in /// Data/social_apps.json (never in source): /// { "youtube": {"ClientId":"…apps.googleusercontent.com","ClientSecret":"…"}, /// "tiktok": {"ClientId":"","ClientSecret":"…"}, /// "x": {"ClientId":"…","ClientSecret":"…"}, /// "twitch": {"ClientId":"…","ClientSecret":"…"}, /// "github": {"ClientId":"…","ClientSecret":"…"}, /// "reddit": {"ClientId":"…","ClientSecret":"…"}, /// "instagram":{"ClientId":"…","ClientSecret":"…"}, /// "steam": {"ApiKey":"…"} } // optional: only for the persona name /// A provider with no entry shows "not set up on this server yet" on its card. The redirect /// URI to register with every app is https://camprec.net/api/site/social/{provider}/callback /// (override with "RedirectBase" at the top level of the file). /// Discord and Spotify keep their own link flows (DiscordController / SpotifyLink); this class /// only holds their "show on profile" switch and projects them into the profile rows. /// public static class SocialLinks { public sealed class Link { [LiteDB.BsonId] public string Id { get; set; } = ""; // "{account}|{provider}" public ulong AccountId { get; set; } public string Provider { get; set; } = ""; public string ExternalId { get; set; } = ""; public string Handle { get; set; } = ""; public string DisplayName { get; set; } = ""; public string Url { get; set; } = ""; public bool ShowOnProfile { get; set; } = true; public DateTime LinkedAt { get; set; } = DateTime.UtcNow; } public sealed record Provider(string Key, string Label, bool Oauth); public static readonly Provider[] Providers = { new("discord", "Discord", false), // own flow new("spotify", "Spotify", false), // own flow new("youtube", "YouTube", true), new("tiktok", "TikTok", true), new("x", "X", true), new("twitch", "Twitch", true), new("steam", "Steam", true), // OpenID, no app needed new("github", "GitHub", true), new("reddit", "Reddit", true), new("instagram", "Instagram", true), }; public static Provider? Find(string key) => Providers.FirstOrDefault(p => p.Key == key); private sealed class AppCfg { public string ClientId { get; set; } = ""; public string ClientSecret { get; set; } = ""; public string ApiKey { get; set; } = ""; } private static readonly string ConfigPath = Path.Combine("Data", "social_apps.json"); private static Dictionary _cfg = new(); private static string _redirectBase = "https://gameauth.ddns.net"; private static DateTime _cfgRead; // _redirectBase: the callback host registered with the services (NOT the site host, camprec.net) private static readonly Lazy _db = new(() => new LiteDB.LiteDatabase(Path.Combine("Data", "DBs", "Socials.db"))); private static LiteDB.ILiteCollection Links() { var c = _db.Value.GetCollection("links"); c.EnsureIndex(x => x.AccountId); return c; } private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) }; // state -> (account, provider, pkce verifier), 10 minutes, one use private static readonly ConcurrentDictionary _states = new(); private static void ReadConfig() { if (DateTime.UtcNow - _cfgRead < TimeSpan.FromMinutes(1)) return; _cfgRead = DateTime.UtcNow; try { if (!File.Exists(ConfigPath)) { _cfg = new(); return; } using var doc = JsonDocument.Parse(File.ReadAllText(ConfigPath)); var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var p in doc.RootElement.EnumerateObject()) { if (p.Name.Equals("RedirectBase", StringComparison.OrdinalIgnoreCase)) { _redirectBase = (p.Value.GetString() ?? _redirectBase).TrimEnd('/'); continue; } if (p.Value.ValueKind != JsonValueKind.Object) continue; var a = new AppCfg(); foreach (var f in p.Value.EnumerateObject()) { string v = f.Value.ValueKind == JsonValueKind.String ? f.Value.GetString() ?? "" : ""; if (f.Name.Equals("ClientId", StringComparison.OrdinalIgnoreCase) || f.Name.Equals("ClientKey", StringComparison.OrdinalIgnoreCase)) a.ClientId = v; else if (f.Name.Equals("ClientSecret", StringComparison.OrdinalIgnoreCase)) a.ClientSecret = v; else if (f.Name.Equals("ApiKey", StringComparison.OrdinalIgnoreCase)) a.ApiKey = v; } map[p.Name.ToLowerInvariant()] = a; } _cfg = map; } catch (Exception ex) { Console.WriteLine($"[social] could not read {ConfigPath}: {ex.Message}"); } } private static AppCfg? Cfg(string provider) { ReadConfig(); return _cfg.TryGetValue(provider, out var c) && !string.IsNullOrWhiteSpace(c.ClientId) && !string.IsNullOrWhiteSpace(c.ClientSecret) ? c : null; } private static string SteamApiKey() { ReadConfig(); return _cfg.TryGetValue("steam", out var c) ? c.ApiKey : ""; } public static bool Configured(string provider) => provider == "steam" || Cfg(provider) != null; // ---- admin panel: the app credentials, editable without touching the VPS ---------------- public static string RedirectUriFor(string provider) => RedirectUri(provider); /// What the admin panel shows: ids in the clear, secrets only as "set". public static List AdminView() { _cfgRead = DateTime.MinValue; ReadConfig(); var list = new List(); foreach (var p in Providers) { if (!p.Oauth) continue; _cfg.TryGetValue(p.Key, out var c); list.Add(new { key = p.Key, label = p.Label, clientId = c?.ClientId ?? "", secretSet = !string.IsNullOrEmpty(c?.ClientSecret), apiKeySet = !string.IsNullOrEmpty(c?.ApiKey), usesApiKey = p.Key == "steam", configured = Configured(p.Key), redirectUri = RedirectUri(p.Key) }); } return list; } /// Write one provider's credentials into Data/social_apps.json (blank secret = keep the stored one). public static bool AdminSave(string provider, string? clientId, string? clientSecret, string? apiKey, out string message) { message = ""; var p = Find(provider); if (p == null || !p.Oauth) { message = "Unknown provider."; return false; } try { var root = new Dictionary(); if (File.Exists(ConfigPath)) { using var doc = JsonDocument.Parse(File.ReadAllText(ConfigPath)); foreach (var e in doc.RootElement.EnumerateObject()) { if (e.Value.ValueKind == JsonValueKind.Object) { var d = new Dictionary(); foreach (var f in e.Value.EnumerateObject()) d[f.Name] = f.Value.ValueKind == JsonValueKind.String ? f.Value.GetString() ?? "" : ""; root[e.Name.ToLowerInvariant()] = d; } else root[e.Name] = e.Value.ValueKind == JsonValueKind.String ? e.Value.GetString() ?? "" : ""; } } var cur = root.TryGetValue(provider, out var o) && o is Dictionary dd ? dd : new Dictionary(); if (clientId != null) cur["ClientId"] = clientId.Trim(); if (!string.IsNullOrWhiteSpace(clientSecret)) cur["ClientSecret"] = clientSecret.Trim(); if (!string.IsNullOrWhiteSpace(apiKey)) cur["ApiKey"] = apiKey.Trim(); if (clientId == "" ) { cur.Remove("ClientId"); cur.Remove("ClientSecret"); } if (apiKey == "") cur.Remove("ApiKey"); root[provider] = cur; Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath)!); File.WriteAllText(ConfigPath, JsonSerializer.Serialize(root, new JsonSerializerOptions { WriteIndented = true })); _cfgRead = DateTime.MinValue; ReadConfig(); message = Configured(provider) ? $"{p.Label} sign-in is set up." : $"{p.Label} saved (still needs both a client id and a secret)."; Console.WriteLine($"[social] admin updated {provider} credentials"); return true; } catch (Exception ex) { message = ex.Message; return false; } } private static string RedirectUri(string provider) => $"{(provider == "steam" ? ConfigCampus.SiteUrl : _redirectBase)}/api/site/social/{provider}/callback"; public static Link? Get(ulong account, string provider) => Links().FindById($"{account}|{provider}"); public static List For(ulong account) => Links().Find(x => x.AccountId == account).ToList(); public static void Unlink(ulong account, string provider) { Links().Delete($"{account}|{provider}"); Console.WriteLine($"[social] {account} unlinked {provider}"); } /// The per-service "show on my profile" switch. Discord/Spotify rows are created on demand. public static void SetShow(ulong account, string provider, bool show) { var l = Get(account, provider); if (l == null) { if (provider != "discord" && provider != "spotify") return; l = new Link { Id = $"{account}|{provider}", AccountId = account, Provider = provider }; } l.ShowOnProfile = show; Links().Upsert(l); } public static bool Shows(ulong account, string provider) => Get(account, provider)?.ShowOnProfile ?? true; private static void Save(ulong account, string provider, string externalId, string handle, string display, string url) { var existing = Get(account, provider); var l = new Link { Id = $"{account}|{provider}", AccountId = account, Provider = provider, ExternalId = externalId, Handle = handle, DisplayName = display, Url = url, ShowOnProfile = existing?.ShowOnProfile ?? true, LinkedAt = DateTime.UtcNow }; Links().Upsert(l); Console.WriteLine($"[social] {account} linked {provider} as '{handle}' ({externalId})"); } // ---- start ----------------------------------------------------------------------------- private static string NewState(ulong account, string provider, string verifier = "") { foreach (var kv in _states) if (DateTime.UtcNow - kv.Value.at > TimeSpan.FromMinutes(10)) _states.TryRemove(kv.Key, out _); string state = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); _states[state] = (account, provider, verifier, DateTime.UtcNow); return state; } private static string Q(params (string k, string v)[] kv) => string.Join("&", kv.Select(p => Uri.EscapeDataString(p.k) + "=" + Uri.EscapeDataString(p.v))); /// The service's sign-in URL for this account, or null when the provider isn't set up. public static string? StartUrl(ulong account, string provider) { var p = Find(provider); if (p == null || !p.Oauth) return null; string redirect = RedirectUri(provider); if (provider == "steam") { // Plain return_to (no query string) and a realm with a trailing slash - the exact // shape every Steam OpenID library sends. Steam's new sign-in page answered // "Something went wrong while attempting to sign you in" to the ?state= variant. // The account is taken from the website cookie on the way back instead. return "https://steamcommunity.com/openid/login?" + Q( ("openid.ns", "http://specs.openid.net/auth/2.0"), ("openid.mode", "checkid_setup"), ("openid.return_to", redirect), ("openid.realm", ConfigCampus.SiteUrl + "/"), ("openid.identity", "http://specs.openid.net/auth/2.0/identifier_select"), ("openid.claimed_id", "http://specs.openid.net/auth/2.0/identifier_select")); } var cfg = Cfg(provider); if (cfg == null) return null; switch (provider) { case "youtube": return "https://accounts.google.com/o/oauth2/v2/auth?" + Q(("client_id", cfg.ClientId), ("redirect_uri", redirect), ("response_type", "code"), ("scope", "https://www.googleapis.com/auth/youtube.readonly"), ("state", NewState(account, provider)), ("access_type", "online"), ("prompt", "select_account")); case "tiktok": return "https://www.tiktok.com/v2/auth/authorize/?" + Q(("client_key", cfg.ClientId), ("redirect_uri", redirect), ("response_type", "code"), ("scope", "user.info.basic,user.info.profile"), ("state", NewState(account, provider))); case "x": { string verifier = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); string challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); return "https://twitter.com/i/oauth2/authorize?" + Q(("client_id", cfg.ClientId), ("redirect_uri", redirect), ("response_type", "code"), ("scope", "users.read tweet.read"), ("state", NewState(account, provider, verifier)), ("code_challenge", challenge), ("code_challenge_method", "S256")); } case "twitch": return "https://id.twitch.tv/oauth2/authorize?" + Q(("client_id", cfg.ClientId), ("redirect_uri", redirect), ("response_type", "code"), ("scope", ""), ("state", NewState(account, provider)), ("force_verify", "true")); case "github": return "https://github.com/login/oauth/authorize?" + Q(("client_id", cfg.ClientId), ("redirect_uri", redirect), ("scope", "read:user"), ("state", NewState(account, provider))); case "reddit": return "https://www.reddit.com/api/v1/authorize?" + Q(("client_id", cfg.ClientId), ("redirect_uri", redirect), ("response_type", "code"), ("scope", "identity"), ("state", NewState(account, provider)), ("duration", "temporary")); case "instagram": return "https://api.instagram.com/oauth/authorize?" + Q(("client_id", cfg.ClientId), ("redirect_uri", redirect), ("response_type", "code"), ("scope", "instagram_business_basic"), ("state", NewState(account, provider))); } return null; } private static string Base64Url(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_'); // ---- callback -------------------------------------------------------------------------- /// Outcome: linked / expired / denied / inuse / error / unconfigured. public static async Task Complete(string provider, IQueryCollection query, ulong cookieAccount = 0) { ulong account; string verifier = ""; if (provider == "steam") { if (cookieAccount == 0) return "expired"; // not signed in to the website any more account = cookieAccount; } else { string state = query["state"].ToString(); if (string.IsNullOrEmpty(state) || !_states.TryRemove(state, out var st) || st.provider != provider || DateTime.UtcNow - st.at > TimeSpan.FromMinutes(10)) return "expired"; account = st.account; verifier = st.verifier; } try { string externalId = "", handle = "", display = "", url = ""; if (provider == "steam") { if (query["openid.mode"].ToString() == "cancel") return "denied"; // Verify with Steam: replay every openid.* parameter with mode=check_authentication. var form = new Dictionary(); foreach (var kv in query) if (kv.Key.StartsWith("openid.")) form[kv.Key] = kv.Value.ToString(); form["openid.mode"] = "check_authentication"; using var vreq = new HttpRequestMessage(HttpMethod.Post, "https://steamcommunity.com/openid/login") { Content = new FormUrlEncodedContent(form) }; vreq.Headers.UserAgent.ParseAdd("Campus/1.0 (+https://camprec.net)"); var vr = await _http.SendAsync(vreq); string vb = await vr.Content.ReadAsStringAsync(); if (!vb.Contains("is_valid:true")) { Console.WriteLine($"[social] steam check_authentication for {account} failed: {vb.Trim()}"); return "error"; } string claimed = query["openid.claimed_id"].ToString(); externalId = claimed.TrimEnd('/').Split('/').LastOrDefault() ?? ""; if (!ulong.TryParse(externalId, out _)) return "error"; url = "https://steamcommunity.com/profiles/" + externalId; handle = externalId; display = "Steam"; string key = SteamApiKey(); if (key.Length > 0) { try { var sr = await _http.GetStringAsync($"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key={Uri.EscapeDataString(key)}&steamids={externalId}"); using var sd = JsonDocument.Parse(sr); var pl = sd.RootElement.GetProperty("response").GetProperty("players"); if (pl.GetArrayLength() > 0) { var p0 = pl[0]; display = p0.TryGetProperty("personaname", out var pn) ? pn.GetString() ?? "" : ""; handle = display.Length > 0 ? display : externalId; if (p0.TryGetProperty("profileurl", out var pu)) url = pu.GetString() ?? url; } } catch (Exception ex) { Console.WriteLine($"[social] steam summary for {account}: {ex.Message}"); } } } else { if (!string.IsNullOrEmpty(query["error"].ToString())) return "denied"; string code = query["code"].ToString(); if (string.IsNullOrEmpty(code)) return "denied"; var cfg = Cfg(provider); if (cfg == null) return "unconfigured"; string redirect = RedirectUri(provider); string token = await ExchangeCode(provider, cfg, code, redirect, verifier); if (token.Length == 0) return "error"; (externalId, handle, display, url) = await Identity(provider, cfg, token); if (externalId.Length == 0) return "error"; } // One service account per Campus account. var other = Links().FindOne(l => l.Provider == provider && l.ExternalId == externalId && l.AccountId != account); if (other != null) return "inuse"; Save(account, provider, externalId, handle, display, url); SiteNotifications.ProfileChanged(account); return "linked"; } catch (Exception ex) { Console.WriteLine($"[social] {provider} link for {account} failed: {ex.Message}"); return "error"; } } private static async Task ExchangeCode(string provider, AppCfg cfg, string code, string redirect, string verifier) { string tokenUrl = provider switch { "youtube" => "https://oauth2.googleapis.com/token", "tiktok" => "https://open.tiktokapis.com/v2/oauth/token/", "x" => "https://api.twitter.com/2/oauth2/token", "twitch" => "https://id.twitch.tv/oauth2/token", "github" => "https://github.com/login/oauth/access_token", "reddit" => "https://www.reddit.com/api/v1/access_token", "instagram" => "https://api.instagram.com/oauth/access_token", _ => "" }; if (tokenUrl.Length == 0) return ""; var form = new Dictionary { ["grant_type"] = "authorization_code", ["code"] = code, ["redirect_uri"] = redirect }; using var req = new HttpRequestMessage(HttpMethod.Post, tokenUrl); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); req.Headers.UserAgent.ParseAdd("Campus/1.0 (+https://camprec.net)"); switch (provider) { case "tiktok": form["client_key"] = cfg.ClientId; form["client_secret"] = cfg.ClientSecret; break; case "x": form["client_id"] = cfg.ClientId; form["code_verifier"] = verifier; req.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(cfg.ClientId + ":" + cfg.ClientSecret))); break; case "reddit": req.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(cfg.ClientId + ":" + cfg.ClientSecret))); break; default: form["client_id"] = cfg.ClientId; form["client_secret"] = cfg.ClientSecret; break; } req.Content = new FormUrlEncodedContent(form); var resp = await _http.SendAsync(req); string body = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) { Console.WriteLine($"[social] {provider} token exchange: HTTP {(int)resp.StatusCode} {Trunc(body)}"); return ""; } try { using var doc = JsonDocument.Parse(body); var root = doc.RootElement; if (root.TryGetProperty("access_token", out var at) && at.ValueKind == JsonValueKind.String) return at.GetString() ?? ""; if (root.TryGetProperty("data", out var d) && d.TryGetProperty("access_token", out var at2)) return at2.GetString() ?? ""; } catch { } Console.WriteLine($"[social] {provider} token exchange: no access_token in {Trunc(body)}"); return ""; } private static string Trunc(string s) => s.Length > 300 ? s[..300] : s; private static async Task<(string id, string handle, string display, string url)> Identity(string provider, AppCfg cfg, string token) { string apiUrl = provider switch { "youtube" => "https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true", "tiktok" => "https://open.tiktokapis.com/v2/user/info/?fields=open_id,display_name,username,profile_deep_link", "x" => "https://api.twitter.com/2/users/me", "twitch" => "https://api.twitch.tv/helix/users", "github" => "https://api.github.com/user", "reddit" => "https://oauth.reddit.com/api/v1/me", "instagram" => "https://graph.instagram.com/me?fields=id,username", _ => "" }; using var req = new HttpRequestMessage(HttpMethod.Get, apiUrl); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); req.Headers.UserAgent.ParseAdd("Campus/1.0 (+https://camprec.net)"); if (provider == "twitch") req.Headers.Add("Client-Id", cfg.ClientId); var resp = await _http.SendAsync(req); string body = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) { Console.WriteLine($"[social] {provider} identity: HTTP {(int)resp.StatusCode} {Trunc(body)}"); return ("", "", "", ""); } using var doc = JsonDocument.Parse(body); var r = doc.RootElement; string S(JsonElement e, string k) => e.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : ""; switch (provider) { case "youtube": { if (!r.TryGetProperty("items", out var items) || items.GetArrayLength() == 0) return ("", "", "", ""); var ch = items[0]; var sn = ch.GetProperty("snippet"); string id = S(ch, "id"), title = S(sn, "title"), custom = S(sn, "customUrl"); string handle = custom.Length > 0 ? custom.TrimStart('@') : title; string url = custom.Length > 0 ? "https://www.youtube.com/" + (custom.StartsWith('@') ? custom : "@" + custom) : "https://www.youtube.com/channel/" + id; return (id, handle, title, url); } case "tiktok": { var u = r.GetProperty("data").GetProperty("user"); string id = S(u, "open_id"), un = S(u, "username"), dn = S(u, "display_name"), link = S(u, "profile_deep_link"); string handle = un.Length > 0 ? un : dn; string url = un.Length > 0 ? "https://www.tiktok.com/@" + un : link; return (id, handle, dn, url); } case "x": { var d = r.GetProperty("data"); string un = S(d, "username"); return (S(d, "id"), un, S(d, "name"), "https://x.com/" + un); } case "twitch": { var arr = r.GetProperty("data"); if (arr.GetArrayLength() == 0) return ("", "", "", ""); var d = arr[0]; string login = S(d, "login"); return (S(d, "id"), S(d, "display_name").Length > 0 ? S(d, "display_name") : login, S(d, "display_name"), "https://www.twitch.tv/" + login); } case "github": { string login = S(r, "login"); return (r.TryGetProperty("id", out var gid) ? gid.GetRawText() : "", login, S(r, "name"), S(r, "html_url").Length > 0 ? S(r, "html_url") : "https://github.com/" + login); } case "reddit": { string name = S(r, "name"); return (S(r, "id"), name, name, "https://www.reddit.com/user/" + name); } case "instagram": { string un = S(r, "username"); return (S(r, "id"), un, un, "https://www.instagram.com/" + un); } } return ("", "", "", ""); } // ---- profile projection ------------------------------------------------------------------ /// The chips a profile shows: every linked service whose switch is on, in Providers order. public static List ProfileRows(ulong account, Classes.db_class.player_extra? extra) { var rows = new List(); var links = For(account).ToDictionary(l => l.Provider, l => l); foreach (var p in Providers) { links.TryGetValue(p.Key, out var l); if (p.Key == "discord") { string dn = extra?.DiscordUsername ?? ""; if ((extra?.DiscordId ?? 0) == 0 || dn.Length == 0 || l?.ShowOnProfile == false) continue; rows.Add(new { platform = "discord", label = "Discord", handle = dn, url = "" }); continue; } if (p.Key == "spotify") { var sp = SpotifyLink.Get(account); if (sp == null || sp.Broken || string.IsNullOrEmpty(sp.ProfileUrl) || l?.ShowOnProfile == false) continue; rows.Add(new { platform = "spotify", label = "Spotify", handle = string.IsNullOrEmpty(sp.DisplayName) ? "Spotify" : sp.DisplayName, url = sp.ProfileUrl }); continue; } if (l == null || !l.ShowOnProfile || l.Handle.Length == 0) continue; rows.Add(new { platform = p.Key, label = p.Label, handle = l.Handle, url = l.Url }); } return rows; } /// The Services cards: one entry per provider with its state for the signed-in account. public static List Status(ulong account, Classes.db_class.player_extra? extra) { var links = For(account).ToDictionary(l => l.Provider, l => l); var list = new List(); foreach (var p in Providers) { links.TryGetValue(p.Key, out var l); bool linked; string handle = "", url = ""; if (p.Key == "discord") { linked = (extra?.DiscordId ?? 0) != 0; handle = extra?.DiscordUsername ?? ""; } else if (p.Key == "spotify") { var sp = SpotifyLink.Get(account); linked = sp != null && !sp.Broken; handle = sp?.DisplayName ?? ""; url = sp?.ProfileUrl ?? ""; } else { linked = l != null && l.Handle.Length > 0; handle = l?.Handle ?? ""; url = l?.Url ?? ""; } list.Add(new { key = p.Key, label = p.Label, oauth = p.Oauth, configured = p.Oauth ? Configured(p.Key) : true, linked, handle, url, show = l?.ShowOnProfile ?? true }); } return list; } } }